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