1#!/usr/bin/env ruby
2#
3# Copyright (C) 2017 Denver Gingerich <denver@ossguy.com>
4# Copyright (C) 2017 Stephen Paul Weber <singpolyma@singpolyma.net>
5#
6# This file is part of sgx-catapult.
7#
8# sgx-catapult is free software: you can redistribute it and/or modify it under
9# the terms of the GNU Affero General Public License as published by the Free
10# Software Foundation, either version 3 of the License, or (at your option) any
11# later version.
12#
13# sgx-catapult is distributed in the hope that it will be useful, but WITHOUT
14# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
15# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
16# details.
17#
18# You should have received a copy of the GNU Affero General Public License along
19# with sgx-catapult. If not, see <http://www.gnu.org/licenses/>.
20
21require 'blather/client/dsl'
22require 'json'
23require 'net/http'
24require 'redis/connection/hiredis'
25require 'uri'
26
27require 'goliath/api'
28require 'goliath/server'
29require 'log4r'
30
31if ARGV.size != 8 then
32 puts "Usage: sgx-catapult.rb <component_jid> <component_password> " +
33 "<server_hostname> <server_port> " +
34 "<redis_hostname> <redis_port> <delivery_receipt_url> " +
35 "<http_listen_port>"
36 exit 0
37end
38
39module SGXcatapult
40 extend Blather::DSL
41
42 def self.run
43 client.run
44 end
45
46 # so classes outside this module can write messages, too
47 def self.write(stanza)
48 client.write(stanza)
49 end
50
51 def self.error_msg(orig, query_node, type, name, text = nil)
52 orig.add_child(query_node)
53 orig.type = :error
54
55 error = Nokogiri::XML::Node.new 'error', orig.document
56 error['type'] = type
57 orig.add_child(error)
58
59 suberr = Nokogiri::XML::Node.new name, orig.document
60 suberr['xmlns'] = 'urn:ietf:params:xml:ns:xmpp-stanzas'
61 error.add_child(suberr)
62
63 # TODO: add some explanatory xml:lang='en' text (see text param)
64 puts "RESPONSE3: #{orig.inspect}"
65 return orig
66 end
67
68 setup ARGV[0], ARGV[1], ARGV[2], ARGV[3]
69
70 message :chat?, :body do |m|
71 num_dest = m.to.to_s.split('@', 2)[0]
72
73 if num_dest[0] != '+'
74 # TODO: add text re number not (yet) supported/implmnted
75 write_to_stream error_msg(m.reply, m.body, :modify,
76 'policy-violation')
77 next
78 end
79
80 bare_jid = m.from.to_s.split('/', 2)[0]
81 cred_key = "catapult_cred-" + bare_jid
82
83 conn = Hiredis::Connection.new
84 conn.connect(ARGV[4], ARGV[5].to_i)
85
86 conn.write ["EXISTS", cred_key]
87 if conn.read == 0
88 conn.disconnect
89
90 # TODO: add text re credentials not being registered
91 write_to_stream error_msg(m.reply, m.body, :auth,
92 'registration-required')
93 next
94 end
95
96 conn.write ["LRANGE", cred_key, 0, 3]
97 creds = conn.read
98 conn.disconnect
99
100 uri = URI.parse('https://api.catapult.inetwork.com')
101 http = Net::HTTP.new(uri.host, uri.port)
102 http.use_ssl = true
103 request = Net::HTTP::Post.new('/v1/users/' + creds[0] +
104 '/messages')
105 request.basic_auth creds[1], creds[2]
106 request.add_field('Content-Type', 'application/json')
107 request.body = JSON.dump({
108 'from' => creds[3],
109 'to' => num_dest,
110 'text' => m.body,
111 'tag' => m.id, # TODO: message has it?
112 'receiptRequested' => 'all',
113 'callbackUrl' => ARGV[6]
114 })
115 response = http.request(request)
116
117 puts 'API response to send: ' + response.to_s + ' with code ' +
118 response.code + ', body "' + response.body + '"'
119
120 if response.code != '201'
121 # TODO: add text re unexpected code; mention code number
122 write_to_stream error_msg(m.reply, m.body, :cancel,
123 'internal-server-error')
124 next
125 end
126
127 # TODO: don't echo message; leave in until we rcv msgs properly
128 begin
129 puts "#{m.from.to_s} -> #{m.to.to_s} #{m.body}"
130 msg = Blather::Stanza::Message.new(m.from, 'thx for "' +
131 m.body + '"')
132 msg.from = m.to
133 write_to_stream msg
134 rescue => e
135 # TODO: do something better with this info
136 say m.from, e.inspect
137 end
138 end
139
140 iq '/iq/ns:query', :ns =>
141 'http://jabber.org/protocol/disco#items' do |i, xpath_result|
142
143 write_to_stream i.reply
144 end
145
146 iq '/iq/ns:query', :ns =>
147 'http://jabber.org/protocol/disco#info' do |i, xpath_result|
148
149 msg = i.reply
150 msg.identities = [{:name =>
151 'Soprani.ca Gateway to XMPP - Catapult',
152 :type => 'sms-ctplt', :category => 'gateway'}]
153 msg.features = ["jabber:iq:register",
154 "jabber:iq:gateway", "jabber:iq:private",
155 "http://jabber.org/protocol/disco#info",
156 "http://jabber.org/protocol/commands",
157 "http://jabber.org/protocol/muc"]
158 write_to_stream msg
159 end
160
161 iq '/iq/ns:query', :ns => 'jabber:iq:register' do |i, qn|
162 puts "IQ: #{i.inspect}"
163
164 if i.type == :set
165 xn = qn.children.find { |v| v.element_name == "x" }
166
167 user_id = ''
168 api_token = ''
169 api_secret = ''
170 phone_num = ''
171
172 if xn.nil?
173 user_id = qn.children.find {
174 |v| v.element_name == "nick" }
175 api_token = qn.children.find {
176 |v| v.element_name == "username" }
177 api_secret = qn.children.find {
178 |v| v.element_name == "password" }
179 phone_num = qn.children.find {
180 |v| v.element_name == "phone" }
181 else
182 for field in xn.children
183 if field.element_name == "field"
184 val = field.children.find { |v|
185 v.element_name == "value" }
186
187 case field['var']
188 when 'nick'
189 user_id = val.text
190 when 'username'
191 api_token = val.text
192 when 'password'
193 api_secret = val.text
194 when 'phone'
195 phone_num = val.text
196 else
197 # TODO: error
198 puts "?: " +field['var']
199 end
200 end
201 end
202 end
203
204 if phone_num[0] != '+'
205 # TODO: add text re number not (yet) supported
206 write_to_stream error_msg(i.reply, qn, :modify,
207 'policy-violation')
208 next
209 end
210
211 uri = URI.parse('https://api.catapult.inetwork.com')
212 http = Net::HTTP.new(uri.host, uri.port)
213 http.use_ssl = true
214 request = Net::HTTP::Get.new('/v1/users/' + user_id +
215 '/phoneNumbers/' + phone_num)
216 request.basic_auth api_token, api_secret
217 response = http.request(request)
218
219 puts 'API response: ' + response.to_s + ' with code ' +
220 response.code + ', body "' + response.body + '"'
221
222 if response.code == '200'
223 params = JSON.parse response.body
224 if params['numberState'] == 'enabled'
225 num_key = "catapult_num-" + phone_num
226
227 bare_jid = i.from.to_s.split('/', 2)[0]
228 cred_key = "catapult_cred-" + bare_jid
229
230 # TODO: pre-validate ARGV[5] is integer
231 conn = Hiredis::Connection.new
232 conn.connect(ARGV[4], ARGV[5].to_i)
233
234 conn.write ["EXISTS", num_key]
235 if conn.read == 1
236 conn.disconnect
237
238 # TODO: add txt re num exists
239 write_to_stream error_msg(
240 i.reply, qn, :cancel,
241 'conflict')
242 next
243 end
244
245 conn.write ["EXISTS", cred_key]
246 if conn.read == 1
247 conn.disconnect
248
249 # TODO: add txt re already exist
250 write_to_stream error_msg(
251 i.reply, qn, :cancel,
252 'conflict')
253 next
254 end
255
256 conn.write ["RPUSH",num_key,bare_jid]
257 if conn.read != 1
258 conn.disconnect
259
260 # TODO: catch/relay RuntimeError
261 # TODO: add txt re push failure
262 write_to_stream error_msg(
263 i.reply, qn, :cancel,
264 'internal-server-error')
265 next
266 end
267
268 conn.write ["RPUSH",cred_key,user_id]
269 conn.write ["RPUSH",cred_key,api_token]
270 conn.write ["RPUSH",cred_key,api_secret]
271 conn.write ["RPUSH",cred_key,phone_num]
272
273 for n in 1..4 do
274 # TODO: catch/relay RuntimeError
275 result = conn.read
276 if result != n
277 conn.disconnect
278
279 write_to_stream(
280 error_msg(
281 i.reply, qn, :cancel,
282 'internal-server-error')
283 )
284 next
285 end
286 end
287 conn.disconnect
288
289 write_to_stream i.reply
290 else
291 # TODO: add text re number disabled
292 write_to_stream error_msg(i.reply, qn,
293 :modify, 'not-acceptable')
294 end
295 elsif response.code == '401'
296 # TODO: add text re bad credentials
297 write_to_stream error_msg(i.reply, qn, :auth,
298 'not-authorized')
299 elsif response.code == '404'
300 # TODO: add text re number not found or disabled
301 write_to_stream error_msg(i.reply, qn, :cancel,
302 'item-not-found')
303 else
304 # TODO: add text re misc error, and mention code
305 write_to_stream error_msg(i.reply, qn, :modify,
306 'not-acceptable')
307 end
308
309 elsif i.type == :get
310 orig = i.reply
311
312 msg = Nokogiri::XML::Node.new 'query',orig.document
313 msg['xmlns'] = 'jabber:iq:register'
314 n1 = Nokogiri::XML::Node.new 'instructions',msg.document
315 n1.content= "Enter the information from your Account " +
316 "page as well as the Phone Number\nin your " +
317 "account you want to use (ie. '+12345678901')" +
318 ".\nUser Id is nick, API Token is username, " +
319 "API Secret is password, Phone Number is phone"+
320 ".\n\nThe source code for this gateway is at " +
321 "https://github.com/ossguy/sgx-catapult ." +
322 "\nCopyright (C) 2017 Denver Gingerich, " +
323 "licensed under AGPLv3+."
324 n2 = Nokogiri::XML::Node.new 'nick',msg.document
325 n3 = Nokogiri::XML::Node.new 'username',msg.document
326 n4 = Nokogiri::XML::Node.new 'password',msg.document
327 n5 = Nokogiri::XML::Node.new 'phone',msg.document
328 msg.add_child(n1)
329 msg.add_child(n2)
330 msg.add_child(n3)
331 msg.add_child(n4)
332 msg.add_child(n5)
333
334 x = Blather::Stanza::X.new :form, [
335 {:required => true, :type => :"text-single",
336 :label => 'User Id', :var => 'nick'},
337 {:required => true, :type => :"text-single",
338 :label => 'API Token', :var => 'username'},
339 {:required => true, :type => :"text-private",
340 :label => 'API Secret', :var => 'password'},
341 {:required => true, :type => :"text-single",
342 :label => 'Phone Number', :var => 'phone'}
343 ]
344 x.title= 'Register for ' +
345 'Soprani.ca Gateway to XMPP - Catapult'
346 x.instructions= "Enter the details from your Account " +
347 "page as well as the Phone Number\nin your " +
348 "account you want to use (ie. '+12345678901')" +
349 ".\n\nThe source code for this gateway is at " +
350 "https://github.com/ossguy/sgx-catapult ." +
351 "\nCopyright (C) 2017 Denver Gingerich, " +
352 "licensed under AGPLv3+."
353 msg.add_child(x)
354
355 orig.add_child(msg)
356 puts "RESPONSE2: #{orig.inspect}"
357 write_to_stream orig
358 puts "SENT"
359 end
360 end
361
362 subscription(:request?) do |s|
363 # TODO: are these the best to return? really need '!' here?
364 #write_to_stream s.approve!
365 #write_to_stream s.request!
366 end
367end
368
369[:INT, :TERM].each do |sig|
370 trap(sig) {
371 puts 'Shutting down gateway...'
372 SGXcatapult.shutdown
373 puts 'Gateway has terminated.'
374
375 EM.stop
376 }
377end
378
379class WebhookHandler < Goliath::API
380 def response(env)
381 msg = Blather::Stanza::Message.new('test@localhost', 'hi')
382 SGXcatapult.write(msg)
383
384 [200, {}, "OK"]
385 end
386end
387
388EM.run do
389 SGXcatapult.run
390
391 server = Goliath::Server.new('127.0.0.1', ARGV[7].to_i)
392 server.api = WebhookHandler.new
393 server.app = Goliath::Rack::Builder.build(server.api.class, server.api)
394 server.logger = Log4r::Logger.new('goliath')
395 server.logger.add(Log4r::StdoutOutputter.new('console'))
396 server.logger.level = Log4r::INFO
397 server.start
398end