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