<?php
/**
 * XMPP Prebind for PHP
 *
 * @copyright 2011 Amiado Group AG
 * @author Michael Weibel <michael.weibel@amiadogroup.com>
 */

/**
 * FirePHP for debugging
 */
include 'FirePHP/fb.php';

/**
 * PEAR Auth_SASL
 */
require 'Auth/SASL.php';

/**
 * XMPP Library for connecting to jabber server & receiving sid and rid
 */
class XmppPrebind {

	const XMLNS_BODY    = 'http://jabber.org/protocol/httpbind';
	const XMLNS_BOSH    = 'urn:xmpp:xbosh';
	const XMLNS_CLIENT  = 'jabber:client';
	const XMLNS_SESSION = 'urn:ietf:params:xml:ns:xmpp-session';
	const XMLNS_BIND    = 'urn:ietf:params:xml:ns:xmpp-bind';
	const XMLNS_SASL    = 'urn:ietf:params:xml:ns:xmpp-sasl';
	const XMLNS_VCARD   = 'vcard-temp';

	const XML_LANG      = 'en';
	const CONTENT_TYPE  = 'text/xml charset=utf-8';

	const ENCRYPTION_PLAIN      = 'PLAIN';
	const ENCRYPTION_DIGEST_MD5 = 'DIGEST-MD5';
	const ENCRYPTION_CRAM_MD5 = 'CRAM-MD5';

	const SERVICE_NAME = 'xmpp';

	protected $jabberHost = '';
	protected $boshUri    = '';
	protected $resource   = '';

	protected $debug = false;
	/**
	 * FirePHP Instance
	 *
	 * @var FirePHP
	 */
	protected $firePhp = null;

	protected $useGzip = false;
	protected $useSsl = false;
	protected $encryption = self::ENCRYPTION_PLAIN;

	protected $jid = '';
	protected $password = '';

	protected $rid = '';
	protected $sid = '';

	protected $doSession = false;
	protected $doBind    = false;

	protected $mechanisms = array();

	// the Bosh attributes for use in a client using this prebound session
	protected $wait;
	protected $requests;
	protected $ver;
	protected $polling;
	protected $inactivity;
	protected $hold;
	protected $to;
	protected $ack;
	protected $accept;
	protected $maxpause;

	/**
	 * Session creation response
	 *
	 * @var DOMDocument
	 */
	public $response;

	/**
	 * Create a new XmppPrebind Object with the required params
	 *
	 * @param string $jabberHost Jabber Server Host
	 * @param string $boshUri    Full URI to the http-bind
	 * @param string $resource   Resource identifier
	 * @param bool   $useSsl     Use SSL (not working yet, TODO)
	 * @param bool   $debug      Enable debug
	 */
	public function __construct($jabberHost, $boshUri, $resource, $useSsl = false, $debug = false) {
		$this->jabberHost = $jabberHost;
		$this->boshUri    = $boshUri;
		$this->resource   = $resource;

		$this->useSsl = $useSsl;

		$this->debug = $debug;
		if ($this->debug === true) {
			$this->firePhp = FirePHP::getInstance(true);
			$this->firePhp->setEnabled(true);
		}

		/* TODO: Not working
		 if (function_exists('gzinflate')) {
			$this->useGzip = true;
		}*/

		/*
		 * The client MUST generate a large, random, positive integer for the initial 'rid' (see Security Considerations)
		 * and then increment that value by one for each subsequent request. The client MUST take care to choose an
		 * initial 'rid' that will never be incremented above 9007199254740991 [21] within the session.
		 * In practice, a session would have to be extraordinarily long (or involve the exchange of an extraordinary
		 * number of packets) to exceed the defined limit.
		 *
		 * @link http://xmpp.org/extensions/xep-0124.html#rids
		 */
		if (function_exists('mt_rand')) {
			$this->rid = mt_rand(1000000000, 10000000000);
		} else {
			$this->rid = rand(1000000000, 10000000000);
		}
	}

	/**
	 * connect to the jabber server with the supplied username & password
	 *
	 * @param string $username Username without jabber host
	 * @param string $password Password
	 * @param string $route Route
	 */
	public function connect($username, $password, $route = false) {
		$this->jid      = $username . '@' . $this->jabberHost;

		if($this->resource) {
			$this->jid .= '/' . $this->resource;
		}

		$this->password = $password;

		$response = $this->sendInitialConnection($route);
        if(empty($response)) {
			throw new XmppPrebindConnectionException("No response from server.");
        }

		$body = self::getBodyFromXml($response);
        if ( empty( $body ) )
			throw new XmppPrebindConnectionException("No body could be found in response from server.");
		$this->sid = $body->getAttribute('sid');

		// set the Bosh Attributes
		$this->wait = $body->getAttribute('wait');
		$this->requests = $body->getAttribute('requests');
		$this->ver = $body->getAttribute('ver');
		$this->polling = $body->getAttribute('polling');
		$this->inactivity = $body->getAttribute('inactivity');
		$this->hold = $body->getAttribute('hold');
		$this->to = $body->getAttribute('to');
		$this->accept = $body->getAttribute('accept');
		$this->maxpause = $body->getAttribute('maxpause');

		$this->debug($this->sid, 'sid');

        if(empty($body->firstChild) || empty($body->firstChild->firstChild)) {
			throw new XmppPrebindConnectionException("Child not found in response from server.");
        }
		$mechanisms = $body->getElementsByTagName('mechanism');

		foreach ($mechanisms as $value) {
			$this->mechanisms[] = $value->nodeValue;
		}

		if (in_array(self::ENCRYPTION_DIGEST_MD5, $this->mechanisms)) {
			$this->encryption = self::ENCRYPTION_DIGEST_MD5;
		} elseif (in_array(self::ENCRYPTION_CRAM_MD5, $this->mechanisms)) {
			$this->encryption = self::ENCRYPTION_CRAM_MD5;
		} elseif (in_array(self::ENCRYPTION_PLAIN, $this->mechanisms)) {
			$this->encryption = self::ENCRYPTION_PLAIN;
		} else {
			throw new XmppPrebindConnectionException("No encryption supported by the server is supported by this library.");
		}

		$this->debug($this->encryption, 'encryption used');

		// Assign session creation response
		$this->response = $body;
	}

	/**
	 * Try to authenticate
	 *
	 * @throws XmppPrebindException if invalid login
	 * @return bool
	 */
	public function auth() {
		$auth = Auth_SASL::factory($this->encryption);

		switch ($this->encryption) {
			case self::ENCRYPTION_PLAIN:
				$authXml = $this->buildPlainAuth($auth);
				break;
			case self::ENCRYPTION_DIGEST_MD5:
				$authXml = $this->sendChallengeAndBuildDigestMd5Auth($auth);
				break;
			case self::ENCRYPTION_CRAM_MD5:
				$authXml = $this->sendChallengeAndBuildCramMd5Auth($auth);
				break;
		}
		$response = $this->send($authXml);

		$body = self::getBodyFromXml($response);

		if (!$body->hasChildNodes() || $body->firstChild->nodeName !== 'success') {
			throw new XmppPrebindException("Invalid login");
		}

		$this->sendRestart();
		$this->sendBindIfRequired();
		$this->sendSessionIfRequired();

		return true;
	}

	/**
	 * Get BOSH parameters to properly setup the BOSH client
	 *
	 * @return array
	 */
	public function getBoshInfo()
	{
		return array(
			'wait' => $this->wait,
			'requests' => $this->requests,
			'ver' => $this->ver,
			'polling' => $this->polling,
			'inactivity' => $this->inactivity,
			'hold' => $this->hold,
			'to' => $this->to,
			'ack' => $this->ack,
			'accept' => $this->accept,
			'maxpause' => $this->maxpause,
		);
	}

	/**
	 * Get jid, sid and rid for attaching
	 *
	 * @return array
	 */
	public function getSessionInfo() {
		return array('jid' => $this->jid, 'sid' => $this->sid, 'rid' => $this->rid);
	}

	/**
	 * Debug if debug enabled
	 *
	 * @param string $msg
	 * @param string $label
	 */
	protected function debug($msg, $label = null) {
		if ($this->firePhp) {
			$this->firePhp->log($msg, $label);
		}
	}

	/**
	 * Send xmpp restart message after successful auth
	 *
	 * @return string Response
	 */
	protected function sendRestart() {
		$domDocument = $this->buildBody();
		$body = self::getBodyFromDomDocument($domDocument);
		$body->appendChild(self::getNewTextAttribute($domDocument, 'to', $this->jabberHost));
		$body->appendChild(self::getNewTextAttribute($domDocument, 'xmlns:xmpp', self::XMLNS_BOSH));
		$body->appendChild(self::getNewTextAttribute($domDocument, 'xmpp:restart', 'true'));

		$restartResponse = $this->send($domDocument->saveXML());

		$restartBody = self::getBodyFromXml($restartResponse);
		foreach ($restartBody->childNodes as $bodyChildNodes) {
			if ($bodyChildNodes->nodeName === 'stream:features') {
				foreach ($bodyChildNodes->childNodes as $streamFeatures) {
					if ($streamFeatures->nodeName === 'bind') {
						$this->doBind = true;
					} elseif ($streamFeatures->nodeName === 'session') {
						$this->doSession = true;
					}
				}
			}
		}

		return $restartResponse;
	}

	/**
	 * Send xmpp bind message after restart
	 *
	 * @return string Response
	 */
	protected function sendBindIfRequired() {
		if ($this->doBind) {
			$domDocument = $this->buildBody();
			$body = self::getBodyFromDomDocument($domDocument);

			$iq = $domDocument->createElement('iq');
			$iq->appendChild(self::getNewTextAttribute($domDocument, 'xmlns', self::XMLNS_CLIENT));
			$iq->appendChild(self::getNewTextAttribute($domDocument, 'type', 'set'));
			$iq->appendChild(self::getNewTextAttribute($domDocument, 'id', 'bind_' . rand()));

			$bind = $domDocument->createElement('bind');
			$bind->appendChild(self::getNewTextAttribute($domDocument, 'xmlns', self::XMLNS_BIND));

			$resource = $domDocument->createElement('resource');
			$resource->appendChild($domDocument->createTextNode($this->resource));

			$bind->appendChild($resource);
			$iq->appendChild($bind);
			$body->appendChild($iq);

			return $this->send($domDocument->saveXML());
		}
		return false;
	}

	/**
	 * Send session if there's a session node in the restart response (within stream:features)
	 */
	protected function sendSessionIfRequired() {
		if ($this->doSession) {
			$domDocument = $this->buildBody();
			$body = self::getBodyFromDomDocument($domDocument);

			$iq = $domDocument->createElement('iq');
			$iq->appendChild(self::getNewTextAttribute($domDocument, 'xmlns', self::XMLNS_CLIENT));
			$iq->appendChild(self::getNewTextAttribute($domDocument, 'type', 'set'));
			$iq->appendChild(self::getNewTextAttribute($domDocument, 'id', 'session_auth_' . rand()));

			$session = $domDocument->createElement('session');
			$session->appendChild(self::getNewTextAttribute($domDocument, 'xmlns', self::XMLNS_SESSION));

			$iq->appendChild($session);
			$body->appendChild($iq);

			return $this->send($domDocument->saveXML());
		}
		return false;
	}

	/**
	 * Send initial connection string
	 *
	 * @param string $route
	 * @return string Response
	 */
	protected function sendInitialConnection($route = false) {
		$domDocument = $this->buildBody();
		$body = self::getBodyFromDomDocument($domDocument);

		$waitTime = 60;

		$body->appendChild(self::getNewTextAttribute($domDocument, 'hold', '1'));
		$body->appendChild(self::getNewTextAttribute($domDocument, 'to', $this->jabberHost));
		$body->appendChild(self::getNewTextAttribute($domDocument, 'xmlns:xmpp', self::XMLNS_BOSH));
		$body->appendChild(self::getNewTextAttribute($domDocument, 'xmpp:version', '1.0'));
		$body->appendChild(self::getNewTextAttribute($domDocument, 'wait', $waitTime));

		if ($route)
		{
			$body->appendChild(self::getNewTextAttribute($domDocument, 'route', $route));
		}

		return $this->send($domDocument->saveXML());
	}

	/**
	 * Send challenge request
	 *
	 * @return string Challenge
	 */
	protected function sendChallenge() {
		$domDocument = $this->buildBody();
		$body = self::getBodyFromDomDocument($domDocument);

		$auth = $domDocument->createElement('auth');
		$auth->appendChild(self::getNewTextAttribute($domDocument, 'xmlns', self::XMLNS_SASL));
		$auth->appendChild(self::getNewTextAttribute($domDocument, 'mechanism', $this->encryption));
		$body->appendChild($auth);

		$response = $this->send($domDocument->saveXML());

		$body = $this->getBodyFromXml($response);
		$challenge = base64_decode($body->firstChild->nodeValue);

		return $challenge;
	}

	/**
	 * Build PLAIN auth string
	 *
	 * @param Auth_SASL_Common $auth
	 * @return string Auth XML to send
	 */
	protected function buildPlainAuth(Auth_SASL_Common $auth) {
		$authString = $auth->getResponse(self::getNodeFromJid($this->jid), $this->password, self::getBareJidFromJid($this->jid));
		$authString = base64_encode($authString);
		$this->debug($authString, 'PLAIN Auth String');

		$domDocument = $this->buildBody();
		$body = self::getBodyFromDomDocument($domDocument);

		$auth = $domDocument->createElement('auth');
		$auth->appendChild(self::getNewTextAttribute($domDocument, 'xmlns', self::XMLNS_SASL));
		$auth->appendChild(self::getNewTextAttribute($domDocument, 'mechanism', $this->encryption));
		$auth->appendChild($domDocument->createTextNode($authString));
		$body->appendChild($auth);

		return $domDocument->saveXML();
	}

	/**
	 * Send challenge request and build DIGEST-MD5 auth string
	 *
	 * @param Auth_SASL_Common $auth
	 * @return string Auth XML to send
	 */
	protected function sendChallengeAndBuildDigestMd5Auth(Auth_SASL_Common $auth) {
		$challenge = $this->sendChallenge();

		$authString = $auth->getResponse(self::getNodeFromJid($this->jid), $this->password, $challenge, $this->jabberHost, self::SERVICE_NAME);
		$this->debug($authString, 'DIGEST-MD5 Auth String');

		$authString = base64_encode($authString);

		$domDocument = $this->buildBody();
		$body = self::getBodyFromDomDocument($domDocument);

		$response = $domDocument->createElement('response');
		$response->appendChild(self::getNewTextAttribute($domDocument, 'xmlns', self::XMLNS_SASL));
		$response->appendChild($domDocument->createTextNode($authString));

		$body->appendChild($response);


		$challengeResponse = $this->send($domDocument->saveXML());

		return $this->replyToChallengeResponse($challengeResponse);
	}

	/**
	 * Send challenge request and build CRAM-MD5 auth string
	 *
	 * @param Auth_SASL_Common $auth
	 * @return string Auth XML to send
	 */
	protected function sendChallengeAndBuildCramMd5Auth(Auth_SASL_Common $auth) {
		$challenge = $this->sendChallenge();

		$authString = $auth->getResponse(self::getNodeFromJid($this->jid), $this->password, $challenge);
		$this->debug($authString, 'CRAM-MD5 Auth String');

		$authString = base64_encode($authString);

		$domDocument = $this->buildBody();
		$body = self::getBodyFromDomDocument($domDocument);

		$response = $domDocument->createElement('response');
		$response->appendChild(self::getNewTextAttribute($domDocument, 'xmlns', self::XMLNS_SASL));
		$response->appendChild($domDocument->createTextNode($authString));

		$body->appendChild($response);

		$challengeResponse = $this->send($domDocument->saveXML());

		return $this->replyToChallengeResponse($challengeResponse);
	}

	/**
	 * CRAM-MD5 and DIGEST-MD5 reply with an additional challenge response which must be replied to.
	 * After this additional reply, the server should reply with "success".
	 */
	protected function replyToChallengeResponse($challengeResponse) {
		$body = self::getBodyFromXml($challengeResponse);
		$challenge = base64_decode((string)$body->firstChild->nodeValue);
		if (strpos($challenge, 'rspauth') === false) {
			throw new XmppPrebindConnectionException('Invalid challenge response received');
		}

		$domDocument = $this->buildBody();
		$body = self::getBodyFromDomDocument($domDocument);
		$response = $domDocument->createElement('response');
		$response->appendChild(self::getNewTextAttribute($domDocument, 'xmlns', self::XMLNS_SASL));

		$body->appendChild($response);

		return $domDocument->saveXML();
	}

	/**
	 * Send XML via CURL
	 *
	 * @param string $xml
	 * @return string Response
	 */
	protected function send($xml) {
		$ch = curl_init($this->boshUri);
		curl_setopt($ch, CURLOPT_HEADER, 0);
		curl_setopt($ch, CURLOPT_POST, 1);
		curl_setopt($ch, CURLOPT_POSTFIELDS, $xml);
		curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);

		$header = array('Content-Type: ' . self::CONTENT_TYPE);
		if ($this->useGzip) {
			$header[] = 'Accept-Encoding: gzip, deflate';
		}
		curl_setopt($ch, CURLOPT_HTTPHEADER, $header);

		curl_setopt($ch, CURLOPT_VERBOSE, 0);
		curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);

		$response = curl_exec($ch);

		// Check if curl failed to get response
		if ($response === false) {
			throw new XmppPrebindConnectionException("Cannot connect to service");
		}

		curl_close($ch);

		if ($this->useGzip) {
			$response = self::compatibleGzInflate($response);
		}

		$this->debug($xml, 'SENT');
		$this->debug($response, 'RECV:');

		return $response;
	}

	/**
	 * Fix gzdecompress/gzinflate data error warning.
	 *
	 * @link http://www.mydigitallife.info/2010/01/17/workaround-to-fix-php-warning-gzuncompress-or-gzinflate-data-error-in-wordpress-http-php/
	 *
	 * @param string $gzData
	 * @return string|bool
	 */
	public static function compatibleGzInflate($gzData) {
		if ( substr($gzData, 0, 3) == "\x1f\x8b\x08" ) {
			$i = 10;
			$flg = ord( substr($gzData, 3, 1) );
			if ( $flg > 0 ) {
				if ( $flg & 4 ) {
					list($xlen) = unpack('v', substr($gzData, $i, 2) );
					$i = $i + 2 + $xlen;
				}
				if ( $flg & 8 )
					$i = strpos($gzData, "\0", $i) + 1;
				if ( $flg & 16 )
					$i = strpos($gzData, "\0", $i) + 1;
				if ( $flg & 2 )
					$i = $i + 2;
			}
			return gzinflate( substr($gzData, $i, -8) );
		} else {
			return false;
		}
	}

	/**
	 * Build DOMDocument with standard xmpp body child node.
	 *
	 * @return DOMDocument
	 */
	protected function buildBody() {
		$xml = new DOMDocument('1.0', 'UTF-8');

		$body = $xml->createElement('body');
		$xml->appendChild($body);

		$body->appendChild(self::getNewTextAttribute($xml, 'xmlns', self::XMLNS_BODY));
		$body->appendChild(self::getNewTextAttribute($xml, 'content', self::CONTENT_TYPE));
		$body->appendChild(self::getNewTextAttribute($xml, 'rid', $this->getAndIncrementRid()));
		$body->appendChild(self::getNewTextAttribute($xml, 'xml:lang', self::XML_LANG));

		if ($this->sid != '') {
			$body->appendChild(self::getNewTextAttribute($xml, 'sid', $this->sid));
		}

		return $xml;
	}

	/**
	 * Get jid in form of username@jabberHost
	 *
	 * @param string $jid Jid in form username@jabberHost/Resource
	 * @return string JID
	 */
	public static function getBareJidFromJid($jid) {
		if ($jid == '') {
			return '';
		}
		$splittedJid = explode('/', $jid, 1);
		return $splittedJid[0];
	}

	/**
	 * Get node (username) from jid
	 *
	 * @param string $jid
	 * @return string Node
	 */
	public static function getNodeFromJid($jid) {
		$atPos = strpos($jid, '@');
		if ($atPos === false) {
			return '';
		}
		return substr($jid, 0, $atPos);
	}

	/**
	 * Append new attribute to existing DOMDocument.
	 *
	 * @param DOMDocument $domDocument
	 * @param string $attributeName
	 * @param string $value
	 * @return DOMNode
	 */
	protected static function getNewTextAttribute($domDocument, $attributeName, $value) {
		$attribute = $domDocument->createAttribute($attributeName);
		$attribute->appendChild($domDocument->createTextNode($value));

		return $attribute;
	}

	/**
	 * Get body node from DOMDocument
	 *
	 * @param DOMDocument $domDocument
	 * @return DOMNode
	 */
	protected static function getBodyFromDomDocument($domDocument) {
		$body = $domDocument->getElementsByTagName('body');
		return $body->item(0);
	}

	/**
	 * Parse XML and return DOMNode of the body
	 *
	 * @uses XmppPrebind::getBodyFromDomDocument()
	 * @param string $xml
	 * @return DOMNode
	 */
	protected static function getBodyFromXml($xml) {
		$domDocument = new DOMDocument();
		$domDocument->loadXml($xml);

		return self::getBodyFromDomDocument($domDocument);
	}

	/**
	 * Get the rid and increment it by one.
	 * Required by RFC
	 *
	 * @return int
	 */
	protected function getAndIncrementRid() {
		return $this->rid++;
	}
}

/**
 * Standard XmppPrebind Exception
 */
class XmppPrebindException extends Exception{}

class XmppPrebindConnectionException extends XmppPrebindException{}
