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