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