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