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