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 'em-hiredis'
23require 'em-http-request'
24require 'json'
25require 'securerandom'
26require 'time'
27require 'uri'
28require 'webrick'
29
30require 'goliath/api'
31require 'goliath/server'
32require 'log4r'
33
34require_relative 'em_promise'
35
36def panic(e)
37 puts "Shutting down gateway due to exception: #{e.message}"
38 puts e.backtrace
39 SGXcatapult.shutdown
40 puts 'Gateway has terminated.'
41 EM.stop
42end
43
44def extract_shortcode(dest)
45 num, context = dest.split(';', 2)
46 num if context && context == 'phone-context=ca-us.phone-context.soprani.ca'
47end
48
49class SGXClient < Blather::Client
50 def register_handler(type, *guards, &block)
51 super(type, *guards) { |stanza| wrap_handler(stanza, &block) }
52 end
53
54 def register_handler_before(type, *guards, &block)
55 check_handler(type, guards)
56 handler = lambda { |stanza| wrap_handler(stanza, &block) }
57
58 @handlers[type] ||= []
59 @handlers[type].unshift([guards, handler])
60 end
61
62protected
63
64 def wrap_handler(stanza, &block)
65 v = block.call(stanza)
66 v.catch(&method(:panic)) if v.is_a?(Promise)
67 true # Do not run other handlers unless throw :pass
68 rescue Exception => e
69 panic(e)
70 end
71end
72
73module SGXcatapult
74 extend Blather::DSL
75
76 @jingle_sids = {}
77 @jingle_fnames = {}
78 @partial_data = {}
79 @client = SGXClient.new
80 @gateway_features = [
81 "http://jabber.org/protocol/disco#info",
82 "jabber:iq:register"
83 ]
84
85 def self.run
86 client.run
87 end
88
89 # so classes outside this module can write messages, too
90 def self.write(stanza)
91 client.write(stanza)
92 end
93
94 def self.before_handler(type, *guards, &block)
95 client.register_handler_before(type, *guards, &block)
96 end
97
98 def self.send_media(from, to, media_url, desc=nil, subject=nil)
99 # we assume media_url is of the form (always the case so far):
100 # https://api.catapult.inetwork.com/v1/users/[uid]/media/[file]
101
102 # the caller must guarantee that 'to' is a bare JID
103 proxy_url = ARGV[6] + to + '/' + media_url.split('/', 8)[7]
104
105 puts 'ORIG_URL: ' + media_url
106 puts 'PROX_URL: ' + proxy_url
107
108 # put URL in the body (so Conversations will still see it)...
109 msg = Blather::Stanza::Message.new(to, proxy_url)
110 msg.from = from
111 msg.subject = subject if subject
112
113 # ...but also provide URL in XEP-0066 (OOB) fashion
114 # TODO: confirm client supports OOB or don't send this
115 x = Nokogiri::XML::Node.new 'x', msg.document
116 x['xmlns'] = 'jabber:x:oob'
117
118 urln = Nokogiri::XML::Node.new 'url', msg.document
119 urlc = Nokogiri::XML::Text.new proxy_url, msg.document
120 urln.add_child(urlc)
121 x.add_child(urln)
122
123 if desc
124 descn = Nokogiri::XML::Node.new('desc', msg.document)
125 descc = Nokogiri::XML::Text.new(desc, msg.document)
126 descn.add_child(descc)
127 x.add_child(descn)
128 end
129
130 msg.add_child(x)
131
132 write(msg)
133 rescue Exception => e
134 panic(e)
135 end
136
137 def self.error_msg(orig, query_node, type, name, text=nil)
138 if not query_node.nil?
139 orig.add_child(query_node)
140 orig.type = :error
141 end
142
143 error = Nokogiri::XML::Node.new 'error', orig.document
144 error['type'] = type
145 orig.add_child(error)
146
147 suberr = Nokogiri::XML::Node.new name, orig.document
148 suberr['xmlns'] = 'urn:ietf:params:xml:ns:xmpp-stanzas'
149 error.add_child(suberr)
150
151 # TODO: add some explanatory xml:lang='en' text (see text param)
152 puts "RESPONSE3: #{orig.inspect}"
153 return orig
154 end
155
156 # workqueue_count MUST be 0 or else Blather uses threads!
157 setup ARGV[0], ARGV[1], ARGV[2], ARGV[3], nil, nil, workqueue_count: 0
158
159 def self.pass_on_message(m, users_num, jid)
160 # setup delivery receipt; similar to a reply
161 rcpt = ReceiptMessage.new(m.from.stripped)
162 rcpt.from = m.to
163
164 # pass original message (before sending receipt)
165 m.to = jid
166 m.from = "#{users_num}@#{ARGV[0]}"
167
168 puts 'XRESPONSE0: ' + m.inspect
169 write_to_stream m
170
171 # send a delivery receipt back to the sender
172 # TODO: send only when requested per XEP-0184
173 # TODO: pass receipts from target if supported
174
175 # TODO: put in member/instance variable
176 rcpt['id'] = SecureRandom.uuid
177 rcvd = Nokogiri::XML::Node.new 'received', rcpt.document
178 rcvd['xmlns'] = 'urn:xmpp:receipts'
179 rcvd['id'] = m.id
180 rcpt.add_child(rcvd)
181
182 puts 'XRESPONSE1: ' + rcpt.inspect
183 write_to_stream rcpt
184 end
185
186 def self.call_catapult(
187 token, secret, m, pth, body=nil,
188 head={}, code=[200], respond_with=:body
189 )
190 EM::HttpRequest.new(
191 "https://api.catapult.inetwork.com/#{pth}"
192 ).public_send(
193 m,
194 head: {
195 'Authorization' => [token, secret]
196 }.merge(head),
197 body: body
198 ).then { |http|
199 puts "API response to send: #{http.response} with code"\
200 " response.code #{http.response_header.status}"
201
202 if code.include?(http.response_header.status)
203 case respond_with
204 when :body
205 http.response
206 when :headers
207 http.response_header
208 else
209 http
210 end
211 else
212 EMPromise.reject(http.response_header.status)
213 end
214 }
215 end
216
217 def self.to_catapult(s, murl, num_dest, user_id, token, secret, usern)
218 extra = if murl
219 {
220 media: murl
221 }
222 else
223 {
224 receiptRequested: 'all',
225 callbackUrl: ARGV[4]
226 }
227 end
228
229 call_catapult(
230 token,
231 secret,
232 :post,
233 "v1/users/#{user_id}/messages",
234 JSON.dump(extra.merge(
235 from: usern,
236 to: num_dest,
237 text: s.respond_to?(:body) ? s.body : '',
238 tag:
239 # callbacks need id and resourcepart
240 WEBrick::HTTPUtils.escape(s.id.to_s) +
241 ' ' +
242 WEBrick::HTTPUtils.escape(
243 s.from.resource.to_s
244 )
245 )),
246 {'Content-Type' => 'application/json'},
247 [201]
248 ).catch {
249 # TODO: add text; mention code number
250 EMPromise.reject(
251 [:cancel, 'internal-server-error']
252 )
253 }
254 end
255
256 def self.validate_num(num)
257 EMPromise.resolve(num.to_s).then { |num_dest|
258 if num_dest =~ /\A\+?[0-9]+(?:;.*)?\Z/
259 next num_dest if num_dest[0] == '+'
260 shortcode = extract_shortcode(num_dest)
261 next shortcode if shortcode
262 end
263 # TODO: text re num not (yet) supportd/implmentd
264 EMPromise.reject([:cancel, 'item-not-found'])
265 }
266 end
267
268 def self.fetch_catapult_cred_for(jid)
269 cred_key = "catapult_cred-#{jid.stripped}"
270 REDIS.lrange(cred_key, 0, 3).then { |creds|
271 if creds.length < 4
272 # TODO: add text re credentials not registered
273 EMPromise.reject(
274 [:auth, 'registration-required']
275 )
276 else
277 creds
278 end
279 }
280 end
281
282 message :chat?, :body do |m|
283 EMPromise.all([
284 validate_num(m.to.node),
285 fetch_catapult_cred_for(m.from)
286 ]).then { |(num_dest, creds)|
287 jid_key = "catapult_jid-#{num_dest}"
288 REDIS.get(jid_key).then { |jid|
289 [jid, num_dest] + creds
290 }
291 }.then { |(jid, num_dest, *creds)|
292 # if destination user is in the system pass on directly
293 if jid
294 pass_on_message(m, creds.last, jid)
295 else
296 to_catapult(m, nil, num_dest, *creds)
297 end
298 }.catch { |e|
299 if e.is_a?(Array) && e.length == 2
300 write_to_stream error_msg(m.reply, m.body, *e)
301 else
302 EMPromise.reject(e)
303 end
304 }
305 end
306
307 def self.user_cap_identities
308 [{category: 'client', type: 'sms'}]
309 end
310
311 def self.user_cap_features
312 [
313 "urn:xmpp:receipts",
314 "urn:xmpp:jingle:1", "urn:xmpp:jingle:transports:ibb:1",
315
316 # TODO: add more efficient file transfer mechanisms
317 #"urn:xmpp:jingle:transports:s5b:1",
318
319 # TODO: MUST add all reasonable vers of file-transfer
320 #"urn:xmpp:jingle:apps:file-transfer:4"
321 "urn:xmpp:jingle:apps:file-transfer:3"
322 ]
323 end
324
325 def self.add_gateway_feature(feature)
326 @gateway_features << feature
327 @gateway_features.uniq!
328 end
329
330 subscription :request? do |p|
331 puts "PRESENCE1: #{p.inspect}"
332
333 # subscriptions are allowed from anyone - send reply immediately
334 msg = Blather::Stanza::Presence.new
335 msg.to = p.from
336 msg.from = p.to
337 msg.type = :subscribed
338
339 puts 'RESPONSE5a: ' + msg.inspect
340 write_to_stream msg
341
342 # send a <presence> immediately; not automatically probed for it
343 # TODO: refactor so no "presence :probe? do |p|" duplicate below
344 caps = Blather::Stanza::Capabilities.new
345 # TODO: user a better node URI (?)
346 caps.node = 'http://catapult.sgx.soprani.ca/'
347 caps.identities = user_cap_identities
348 caps.features = user_cap_features
349
350 msg = caps.c
351 msg.to = p.from
352 msg.from = p.to.to_s + '/sgx'
353
354 puts 'RESPONSE5b: ' + msg.inspect
355 write_to_stream msg
356
357 # need to subscribe back so Conversations displays images inline
358 msg = Blather::Stanza::Presence.new
359 msg.to = p.from.to_s.split('/', 2)[0]
360 msg.from = p.to.to_s.split('/', 2)[0]
361 msg.type = :subscribe
362
363 puts 'RESPONSE5c: ' + msg.inspect
364 write_to_stream msg
365 end
366
367 presence :probe? do |p|
368 puts 'PRESENCE2: ' + p.inspect
369
370 caps = Blather::Stanza::Capabilities.new
371 # TODO: user a better node URI (?)
372 caps.node = 'http://catapult.sgx.soprani.ca/'
373 caps.identities = user_cap_identities
374 caps.features = user_cap_features
375
376 msg = caps.c
377 msg.to = p.from
378 msg.from = p.to.to_s + '/sgx'
379
380 puts 'RESPONSE6: ' + msg.inspect
381 write_to_stream msg
382 end
383
384 iq '/iq/ns:jingle', ns: 'urn:xmpp:jingle:1' do |i, jn|
385 puts "IQj: #{i.inspect}"
386
387 if jn[0]['action'] == 'transport-accept'
388 puts "REPLY0: #{i.reply.inspect}"
389 write_to_stream i.reply
390 next
391 elsif jn[0]['action'] == 'session-terminate'
392 # TODO: unexpected (usually we do this; handle?)
393 puts "TERMINATED"
394 next
395 elsif jn[0]['action'] == 'transport-info'
396 # TODO: unexpected, but should handle in a nice way
397 puts "FAIL!!!"
398 next
399 elsif i.type == :error
400 # TODO: do something, maybe terminating the connection
401 puts 'ERROR!!!'
402 next
403 end
404
405 # TODO: should probably confirm we got session-initiate here
406
407 write_to_stream i.reply
408 puts "RESPONSE8: #{i.reply.inspect}"
409
410 msg = Blather::Stanza::Iq.new :set
411 msg.to = i.from
412 msg.from = i.to
413
414 cn = jn.children.find { |v| v.element_name == "content" }
415 puts 'CN-name: ' + cn['name']
416 puts 'JN-sid: ' + jn[0]['sid']
417
418 ibb_found = false
419 last_sid = ''
420 cn.children.each do |child|
421 if child.element_name == 'transport'
422 puts 'TPORT: ' + child.namespace.href
423 last_sid = child['sid']
424 if 'urn:xmpp:jingle:transports:ibb:1' ==
425 child.namespace.href
426
427 ibb_found = true
428 break
429 end
430 end
431 end
432
433 j = Nokogiri::XML::Node.new 'jingle', msg.document
434 j['xmlns'] = 'urn:xmpp:jingle:1'
435 j['sid'] = jn[0]['sid']
436 msg.add_child(j)
437
438 content = Nokogiri::XML::Node.new 'content', msg.document
439 content['name'] = cn['name']
440 content['creator'] = 'initiator'
441 j.add_child(content)
442
443 transport = Nokogiri::XML::Node.new 'transport', msg.document
444 # TODO: make block-size more variable and/or dependent on sender
445 transport['block-size'] = '4096'
446 transport['xmlns'] = 'urn:xmpp:jingle:transports:ibb:1'
447 if ibb_found
448 transport['sid'] = last_sid
449 j['action'] = 'session-accept'
450 j['responder'] = i.from
451
452 dsc = Nokogiri::XML::Node.new 'description', msg.document
453 dsc['xmlns'] = 'urn:xmpp:jingle:apps:file-transfer:3'
454 content.add_child(dsc)
455 else
456 # for Conversations - it tries s5b even if caps ibb-only
457 transport['sid'] = SecureRandom.uuid
458 j['action'] = 'transport-replace'
459 j['initiator'] = i.from
460 end
461 content.add_child(transport)
462
463 @jingle_sids[transport['sid']] = jn[0]['sid']
464
465 # TODO: save <date> as well? Gajim sends, Conversations does not
466 # TODO: save/validate <size> with eventual full received length
467 fname =
468 cn
469 .children.find { |v| v.element_name == "description" }
470 .children.find { |w| w.element_name == "offer" }
471 .children.find { |x| x.element_name == "file" }
472 .children.find { |y| y.element_name == "name" }
473 @jingle_fnames[transport['sid']] = fname.text
474
475 puts "RESPONSE9: #{msg.inspect}"
476 write_to_stream msg
477 end
478
479 iq '/iq/ns:open', ns: 'http://jabber.org/protocol/ibb' do |i, on|
480 puts "IQo: #{i.inspect}"
481
482 @partial_data[on[0]['sid']] = ''
483 write_to_stream i.reply
484 end
485
486 iq '/iq/ns:data', ns: 'http://jabber.org/protocol/ibb' do |i, dn|
487 @partial_data[dn[0]['sid']] += Base64.decode64(dn[0].text)
488 write_to_stream i.reply
489 end
490
491 iq '/iq/ns:close', ns: 'http://jabber.org/protocol/ibb' do |i, cn|
492 puts "IQc: #{i.inspect}"
493 write_to_stream i.reply
494
495 EMPromise.all([
496 validate_num(i.to.node),
497 fetch_catapult_cred_for(i.from)
498 ]).then { |(num_dest, creds)|
499 # Gajim bug: <close/> has Jingle (not transport) sid; fix later
500 if not @jingle_fnames.key? cn[0]['sid']
501 puts 'ERROR: Not found in filename map: ' + cn[0]['sid']
502
503 next EMPromise.reject(:done)
504 # TODO: in case only Gajim's <data/> bug fixed, add map:
505 #cn[0]['sid'] = @jingle_tsids[cn[0]['sid']]
506 end
507
508 # upload cached data to server (before success reply)
509 media_name =
510 "#{Time.now.utc.iso8601}_#{SecureRandom.uuid}"\
511 "_#{@jingle_fnames[cn[0]['sid']]}"
512 puts 'name to save: ' + media_name
513
514 path = "/v1/users/#{creds.first}/media/#{media_name}"
515
516 EMPromise.all([
517 call_catapult(
518 *creds[1..2],
519 :put,
520 path,
521 @partial_data[cn[0]['sid']]
522 ).catch {
523 EMPromise.reject([
524 :cancel, 'internal-server-error'
525 ])
526 },
527 to_catapult(
528 i,
529 "https://api.catapult.inetwork.com/" +
530 path,
531 num_dest,
532 *creds
533 )
534 ])
535 }.then {
536 @partial_data[cn[0]['sid']] = ''
537
538 # received the complete file so now close the stream
539 msg = Blather::Stanza::Iq.new :set
540 msg.to = i.from
541 msg.from = i.to
542
543 j = Nokogiri::XML::Node.new 'jingle', msg.document
544 j['xmlns'] = 'urn:xmpp:jingle:1'
545 j['action'] = 'session-terminate'
546 j['sid'] = @jingle_sids[cn[0]['sid']]
547 msg.add_child(j)
548
549 r = Nokogiri::XML::Node.new 'reason', msg.document
550 s = Nokogiri::XML::Node.new 'success', msg.document
551 r.add_child(s)
552 j.add_child(r)
553
554 puts 'RESPONSE1: ' + msg.inspect
555 write_to_stream msg
556 }.catch { |e|
557 if e.is_a?(Array) && e.length == 2
558 write_to_stream error_msg(i.reply, nil, *e)
559 elsif e != :done
560 EMPromise.reject(e)
561 end
562 }
563 end
564
565 iq '/iq/ns:query', ns: 'http://jabber.org/protocol/disco#info' do |i|
566 # respond to capabilities request for an sgx-catapult number JID
567 if i.to.node
568 # TODO: confirm the node URL is expected using below
569 #puts "XR[node]: #{xpath_result[0]['node']}"
570
571 msg = i.reply
572 msg.identities = user_cap_identities
573 msg.features = user_cap_features
574
575 puts 'RESPONSE7: ' + msg.inspect
576 write_to_stream msg
577 next
578 end
579
580 # respond to capabilities request for sgx-catapult itself
581 msg = i.reply
582 msg.identities = [{
583 name: 'Soprani.ca Gateway to XMPP - Catapult',
584 type: 'sms', category: 'gateway'
585 }]
586 msg.features = @gateway_features
587 write_to_stream msg
588 end
589
590 def self.check_then_register(i, *creds)
591 jid_key = "catapult_jid-#{creds.last}"
592 bare_jid = i.from.stripped
593 cred_key = "catapult_cred-#{bare_jid}"
594
595 REDIS.get(jid_key).then { |existing_jid|
596 if existing_jid && existing_jid != bare_jid
597 # TODO: add/log text: credentials exist already
598 EMPromise.reject([:cancel, 'conflict'])
599 end
600 }.then {
601 REDIS.lrange(cred_key, 0, 3)
602 }.then { |existing_creds|
603 # TODO: add/log text: credentials exist already
604 if existing_creds.length == 4 && creds != existing_creds
605 EMPromise.reject([:cancel, 'conflict'])
606 elsif existing_creds.length < 4
607 REDIS.rpush(cred_key, *creds).then { |length|
608 if length != 4
609 EMPromise.reject([
610 :cancel,
611 'internal-server-error'
612 ])
613 end
614 }
615 end
616 }.then {
617 # not necessary if existing_jid non-nil, easier this way
618 REDIS.set(jid_key, bare_jid)
619 }.then { |result|
620 if result != 'OK'
621 # TODO: add txt re push failure
622 EMPromise.reject(
623 [:cancel, 'internal-server-error']
624 )
625 end
626 }.then {
627 write_to_stream i.reply
628 }
629 end
630
631 def self.creds_from_registration_query(qn)
632 xn = qn.children.find { |v| v.element_name == "x" }
633
634 if xn
635 xn.children.each_with_object({}) do |field, h|
636 next if field.element_name != "field"
637 val = field.children.find { |v|
638 v.element_name == "value"
639 }
640
641 case field['var']
642 when 'nick'
643 h[:user_id] = val.text
644 when 'username'
645 h[:api_token] = val.text
646 when 'password'
647 h[:api_secret] = val.text
648 when 'phone'
649 h[:phone_num] = val.text
650 else
651 # TODO: error
652 puts "?: #{field['var']}"
653 end
654 end
655 else
656 qn.children.each_with_object({}) do |field, h|
657 case field.element_name
658 when "nick"
659 h[:user_id] = field.text
660 when "username"
661 h[:api_token] = field.text
662 when "password"
663 h[:api_secret] = field.text
664 when "phone"
665 h[:phone_num] = field.text
666 end
667 end
668 end.values_at(:user_id, :api_token, :api_secret, :phone_num)
669 end
670
671 def self.process_registration(i, qn)
672 EMPromise.resolve(
673 qn.children.find { |v| v.element_name == "remove" }
674 ).then { |rn|
675 if rn
676 puts "received <remove/> - ignoring for now..."
677 EMPromise.reject(:done)
678 else
679 creds_from_registration_query(qn)
680 end
681 }.then { |user_id, api_token, api_secret, phone_num|
682 if phone_num[0] == '+'
683 [user_id, api_token, api_secret, phone_num]
684 else
685 # TODO: add text re number not (yet) supported
686 EMPromise.reject([:cancel, 'item-not-found'])
687 end
688 }.then { |user_id, api_token, api_secret, phone_num|
689 call_catapult(
690 api_token,
691 api_secret,
692 :get,
693 "v1/users/#{user_id}/phoneNumbers/#{phone_num}"
694 ).then { |response|
695 params = JSON.parse(response)
696 if params['numberState'] == 'enabled'
697 check_then_register(
698 i,
699 user_id,
700 api_token,
701 api_secret,
702 phone_num
703 )
704 else
705 # TODO: add text re number disabled
706 EMPromise.reject([:modify, 'not-acceptable'])
707 end
708 }
709 }.catch { |e|
710 EMPromise.reject(case e
711 when 401
712 # TODO: add text re bad credentials
713 [:auth, 'not-authorized']
714 when 404
715 # TODO: add text re number not found or disabled
716 [:cancel, 'item-not-found']
717 when Integer
718 [:modify, 'not-acceptable']
719 else
720 e
721 end)
722 }
723 end
724
725 def self.registration_form(orig, existing_number=nil)
726 msg = Nokogiri::XML::Node.new 'query', orig.document
727 msg['xmlns'] = 'jabber:iq:register'
728
729 if existing_number
730 msg.add_child(
731 Nokogiri::XML::Node.new(
732 'registered', msg.document
733 )
734 )
735 end
736
737 n1 = Nokogiri::XML::Node.new(
738 'instructions', msg.document
739 )
740 n1.content = "Enter the information from your Account "\
741 "page as well as the Phone Number\nin your "\
742 "account you want to use (ie. '+12345678901')"\
743 ".\nUser Id is nick, API Token is username, "\
744 "API Secret is password, Phone Number is phone"\
745 ".\n\nThe source code for this gateway is at "\
746 "https://gitlab.com/ossguy/sgx-catapult ."\
747 "\nCopyright (C) 2017 Denver Gingerich and "\
748 "others, licensed under AGPLv3+."
749 n2 = Nokogiri::XML::Node.new 'nick', msg.document
750 n3 = Nokogiri::XML::Node.new 'username', msg.document
751 n4 = Nokogiri::XML::Node.new 'password', msg.document
752 n5 = Nokogiri::XML::Node.new 'phone', msg.document
753 n5.content = existing_number.to_s
754 msg.add_child(n1)
755 msg.add_child(n2)
756 msg.add_child(n3)
757 msg.add_child(n4)
758 msg.add_child(n5)
759
760 x = Blather::Stanza::X.new :form, [
761 {
762 required: true, type: :"text-single",
763 label: 'User Id', var: 'nick'
764 },
765 {
766 required: true, type: :"text-single",
767 label: 'API Token', var: 'username'
768 },
769 {
770 required: true, type: :"text-private",
771 label: 'API Secret', var: 'password'
772 },
773 {
774 required: true, type: :"text-single",
775 label: 'Phone Number', var: 'phone',
776 value: existing_number.to_s
777 }
778 ]
779 x.title = 'Register for '\
780 'Soprani.ca Gateway to XMPP - Catapult'
781 x.instructions = "Enter the details from your Account "\
782 "page as well as the Phone Number\nin your "\
783 "account you want to use (ie. '+12345678901')"\
784 ".\n\nThe source code for this gateway is at "\
785 "https://gitlab.com/ossguy/sgx-catapult ."\
786 "\nCopyright (C) 2017 Denver Gingerich and "\
787 "others, licensed under AGPLv3+."
788 msg.add_child(x)
789
790 orig.add_child(msg)
791
792 return orig
793 end
794
795 iq '/iq/ns:query', ns: 'jabber:iq:register' do |i, qn|
796 puts "IQ: #{i.inspect}"
797
798 case i.type
799 when :set
800 process_registration(i, qn)
801 when :get
802 bare_jid = i.from.stripped
803 cred_key = "catapult_cred-#{bare_jid}"
804 REDIS.lindex(cred_key, 3).then { |existing_number|
805 reply = registration_form(i.reply, existing_number)
806 puts "RESPONSE2: #{reply.inspect}"
807 write_to_stream reply
808 }
809 else
810 # Unknown IQ, ignore for now
811 EMPromise.reject(:done)
812 end.catch { |e|
813 if e.is_a?(Array) && e.length == 2
814 write_to_stream error_msg(i.reply, qn, *e)
815 elsif e != :done
816 EMPromise.reject(e)
817 end
818 }.catch(&method(:panic))
819 end
820
821 iq :get? do |i|
822 write_to_stream error_msg(i.reply, i.children, 'cancel', 'feature-not-implemented')
823 end
824
825 iq :set? do |i|
826 write_to_stream error_msg(i.reply, i.children, 'cancel', 'feature-not-implemented')
827 end
828end
829
830class ReceiptMessage < Blather::Stanza
831 def self.new(to=nil)
832 node = super :message
833 node.to = to
834 node
835 end
836end
837
838class WebhookHandler < Goliath::API
839 use Goliath::Rack::Params
840
841 def response(env)
842 puts 'ENV: ' + env.reject{ |k| k == 'params' }.to_s
843
844 users_num = ''
845 others_num = ''
846 if params['direction'] == 'in'
847 users_num = params['to']
848 others_num = params['from']
849 elsif params['direction'] == 'out'
850 users_num = params['from']
851 others_num = params['to']
852 else
853 # TODO: exception or similar
854 puts "big problem: '" + params['direction'] + "'" + body
855 return [200, {}, "OK"]
856 end
857
858 puts 'BODY - messageId: ' + params['messageId'] +
859 ', eventType: ' + params['eventType'] +
860 ', time: ' + params['time'] +
861 ', direction: ' + params['direction'] +
862 ', state: ' + params['state'] +
863 ', deliveryState: ' + (params['deliveryState'] ?
864 params['deliveryState'] : 'NONE') +
865 ', deliveryCode: ' + (params['deliveryCode'] ?
866 params['deliveryCode'] : 'NONE') +
867 ', deliveryDesc: ' + (params['deliveryDescription'] ?
868 params['deliveryDescription'] : 'NONE') +
869 ', tag: ' + (params['tag'] ? params['tag'] : 'NONE') +
870 ', media: ' + (params['media'] ? params['media'].to_s :
871 'NONE')
872
873 if others_num[0] != '+'
874 # TODO: check that others_num actually a shortcode first
875 others_num +=
876 ';phone-context=ca-us.phone-context.soprani.ca'
877 end
878
879 jid_key = "catapult_jid-#{users_num}"
880 bare_jid = REDIS.get(jid_key).promise.sync
881
882 if !bare_jid
883 puts "jid_key (#{jid_key}) DNE; Catapult misconfigured?"
884
885 # TODO: likely not appropriate; give error to Catapult?
886 # TODO: add text re credentials not being registered
887 #write_to_stream error_msg(m.reply, m.body, :auth,
888 # 'registration-required')
889 return [200, {}, "OK"]
890 end
891
892 msg = ''
893 case params['direction']
894 when 'in'
895 text = ''
896 case params['eventType']
897 when 'sms'
898 text = params['text']
899 when 'mms'
900 has_media = false
901 params['media'].each do |media_url|
902 if not media_url.end_with?(
903 '.smil', '.txt', '.xml'
904 )
905
906 has_media = true
907 SGXcatapult.send_media(
908 others_num + '@' +
909 ARGV[0],
910 bare_jid, media_url
911 )
912 end
913 end
914
915 if params['text'].empty?
916 if not has_media
917 text = '[suspected group msg '\
918 'with no text (odd)]'
919 end
920 else
921 text = if has_media
922 # TODO: write/use a caption XEP
923 params['text']
924 else
925 '[suspected group msg '\
926 '(recipient list not '\
927 'available) with '\
928 'following text] ' +
929 params['text']
930 end
931 end
932
933 # ie. if text param non-empty or had no media
934 if not text.empty?
935 msg = Blather::Stanza::Message.new(
936 bare_jid, text)
937 msg.from = others_num + '@' + ARGV[0]
938 SGXcatapult.write(msg)
939 end
940
941 return [200, {}, "OK"]
942 else
943 text = "unknown type (#{params['eventType']})"\
944 " with text: " + params['text']
945
946 # TODO: log/notify of this properly
947 puts text
948 end
949
950 msg = Blather::Stanza::Message.new(bare_jid, text)
951 else # per prior switch, this is: params['direction'] == 'out'
952 tag_parts = params['tag'].split(/ /, 2)
953 id = WEBrick::HTTPUtils.unescape(tag_parts[0])
954 resourcepart = WEBrick::HTTPUtils.unescape(tag_parts[1])
955
956 case params['deliveryState']
957 when 'not-delivered'
958 # create a bare message like the one user sent
959 msg = Blather::Stanza::Message.new(
960 others_num + '@' + ARGV[0])
961 msg.from = bare_jid + '/' + resourcepart
962 msg['id'] = id
963
964 # create an error reply to the bare message
965 msg = Blather::StanzaError.new(
966 msg,
967 'recipient-unavailable',
968 :wait
969 ).to_node
970 when 'delivered'
971 msg = ReceiptMessage.new(bare_jid)
972
973 # TODO: put in member/instance variable
974 msg['id'] = SecureRandom.uuid
975
976 # TODO: send only when requested per XEP-0184
977 rcvd = Nokogiri::XML::Node.new(
978 'received',
979 msg.document
980 )
981 rcvd['xmlns'] = 'urn:xmpp:receipts'
982 rcvd['id'] = id
983 msg.add_child(rcvd)
984 when 'waiting'
985 # can't really do anything with it; nice to know
986 puts "message with id #{id} waiting"
987 return [200, {}, "OK"]
988 else
989 # TODO: notify somehow of unknown state receivd?
990 puts "message with id #{id} has "\
991 "other state #{params['deliveryState']}"
992 return [200, {}, "OK"]
993 end
994
995 puts "RESPONSE4: #{msg.inspect}"
996 end
997
998 msg.from = others_num + '@' + ARGV[0]
999 SGXcatapult.write(msg)
1000
1001 [200, {}, "OK"]
1002
1003 rescue Exception => e
1004 puts 'Shutting down gateway due to exception 013: ' + e.message
1005 SGXcatapult.shutdown
1006 puts 'Gateway has terminated.'
1007 EM.stop
1008 end
1009end
1010
1011at_exit do
1012 $stdout.sync = true
1013
1014 puts "Soprani.ca/SMS Gateway for XMPP - Catapult\n"\
1015 "==>> last commit of this version is " + `git rev-parse HEAD` + "\n"
1016
1017 if ARGV.size != 7
1018 puts "Usage: sgx-catapult.rb <component_jid> "\
1019 "<component_password> <server_hostname> "\
1020 "<server_port> <delivery_receipt_url> "\
1021 "<http_listen_port> <mms_proxy_prefix_url>"
1022 exit 0
1023 end
1024
1025 t = Time.now
1026 puts "LOG %d.%09d: starting...\n\n" % [t.to_i, t.nsec]
1027
1028 EM.run do
1029 REDIS = EM::Hiredis.connect
1030
1031 SGXcatapult.run
1032
1033 # required when using Prosody otherwise disconnects on 6-hour inactivity
1034 EM.add_periodic_timer(3600) do
1035 msg = Blather::Stanza::Iq::Ping.new(:get, 'localhost')
1036 msg.from = ARGV[0]
1037 SGXcatapult.write(msg)
1038 end
1039
1040 server = Goliath::Server.new('0.0.0.0', ARGV[5].to_i)
1041 server.api = WebhookHandler.new
1042 server.app = Goliath::Rack::Builder.build(server.api.class, server.api)
1043 server.logger = Log4r::Logger.new('goliath')
1044 server.logger.add(Log4r::StdoutOutputter.new('console'))
1045 server.logger.level = Log4r::INFO
1046 server.start do
1047 ["INT", "TERM"].each do |sig|
1048 trap(sig) do
1049 EM.defer do
1050 puts 'Shutting down gateway...'
1051 SGXcatapult.shutdown
1052
1053 puts 'Gateway has terminated.'
1054 EM.stop
1055 end
1056 end
1057 end
1058 end
1059 end
1060end