<?php
/**
 * @version		$Id: mollom.php 1336 2009-02-17 13:44:15Z ircmaxell $
 */
 
defined('_JEXEC') or die('Direct Access is not allowed');

/**
 * Mollom Class
 * This source file can be used to communicate with mollom (http://mollom.com)
 *
 * The class is documented in the file itself, but you can find more documentation and examples on the docs-page (http://mollom.crsolutions.be/docs).
 * If you find any bugs help me out and report them. Reporting can be done by sending an email to php-mollom-bugs[at]verkoyen[dot]eu. If you report a bug, make sure you give me enough information (include your code). 
 * If you have questions, try the Mollom-forum, don't send them by mail, I won't read them.
 * 
 * Changelog since 1.0.1
 * - Fixed a nasty bug. Possible infinite loop in doCall().
 * - Fixed getServerList. I misinterpreted the documentation, so now the defaultserver is xmlrpc.mollom.com instead of the first fallback-server.
 * - Fixed the timeout-issue. With fsockopen the timeout on connect wasn't respected. Rewrote the doCall function to use CURL over sockets.
 * 
 * Changelog since 1.1.0
 * - Fixed a problem with the IP-addresses. see http://blog.verkoyen.eu/2008/07/12/important-php-mollom-update/
 * 
 * Changelog since 1.1.1
 * - PHPMollom was using HTTP 1.1, now HTTP 1.0.
 * - Fallbackserver are hardcode, when no servers could be retrieved the fallbacks are used
 * 
 * License
 * Copyright (c) 2008, Tijs Verkoyen. All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
 * - Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
 * - Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
 * - Neither the name of Tijs Verkoyen, PHP Mollom nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
 *
 * This software is provided by the copyright holders and contributors "as is" and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. In no event shall the copyright owner or contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage.
 *
 * @author			Tijs Verkoyen <mollom@verkoyen.eu>
 * @version			1.1.2
 *
 * @copyright		Copyright (c) 2008, Tijs Verkoyen. All rights reserved.
 * @license			http://mollom.local/license BSD License
 *
 * Modified by Anthony Ferrara <development@moovum.com>
 */

final class Mollom {
	//User Configurable Variables
	private $allowed_reverse_proxy_addresses = array();
	private $private_key = null;
	private $public_key = null;
	private $reverse_proxy = false;
	private $server_host = 'http://xmlrpc.mollom.com';
	private $server_list = array();
	private $timeout = 10;
	private $user_agent = 'MollomPHP';

	//State Variables
	private $servers_updated = false;

	//information variables
	private $class_version = '1.2.0';
	private $api_version = '1.0';

	//Static instance variables
	private static $instances = array();
	private static $default_signature = '__default__';

	//allowed content variables
	private $methods = array('checkCaptcha', 'checkContent', 'getAudioCaptcha', 'getImageCaptcha', 'getServerList', 'getStatistics', 'sendFeedback', 'verifyKey');
	private $possible_feedback = array('spam', 'profanity', 'low-quality', 'unwanted');
	private $possible_stats = array('total_days', 'total_accepted', 'total_rejected', 'yesterday_accepted', 'yesterday_rejected', 'today_accepted', 'today_rejected');
	private $default_servers = array('http://xmlrpc1.mollom.com', 'http://xmlrpc2.mollom.com', 'http://xmlrpc3.mollom.com');
	private $content_args = array('session_id', 'post_title', 'post_body', 'author_name', 'author_url', 'author_mail', 'author_openid', 'author_id');

	private function __construct() {
		$this->user_agent .= '/'.$this->class_version;
	}

	public static function &getInstance($config = array()) {
		if(empty($config)) {
			$signature = self::$default_signature;
		} else {
			$signature = md5(@implode('|', $config));
		}

		if(!isset(self::$instances[$signature])) {
			$obj = new Mollom();
			foreach($config AS $key => $value) {
				$obj->set($key, $value);
			}
			self::$instances[$signature] = &$obj;
			if(empty(self::$instances[self::$default_signature])) self::$instances[self::$default_signature] = &$obj;
		}
		return self::$instances[$signature];
	}

	public function __get($var) {
		return isset($this->$var) ? $this->$var : null;
	}

	public function set($key, $value) {
		switch($key) {
			case 'allowed_reverse_proxy_addresses':
				if(is_array($value) && !empty($value)) {
					$this->reverse_proxy = true;
				}
			case 'server_list':
				if(!is_array($value)) {
					throw new InvalidArgumentException('Mollom::set() expects $value to be array for key: '.$key, 66426);
				}
				break;
			case 'private_key':
			case 'public_key':
			case 'user_agent':
			case 'server_host':
				if(!is_string($value)) {
					throw new InvalidArgumentException('Mollom::set() expects $value to be string for key: '.$key, 66425);
				}
				break;
			case 'timeout':
				if(!is_numeric($value)) {
					throw new InvalidArgumentException('Mollom::set() expects $value to be numeric for key: '.$key, 66424);
				}
				break;
			default:
				throw new InvalidArgumentException('Mollom::set() called for illegal $key value: '.$key, 66423);
		}
		$this->$key = $value;
	}

	public function getServersUpdated() {
		return $this->servers_updated;
	}

	public function verifyKey() {
		$response = $this->doCall('verifyKey');
		if(!isset($response->params->param->value->boolean)) throw new Exception('Invalid Response In verifyKey', 66422);
		return $response->params->param->value->boolean == '1' ? true : false;
	}
	
	public function sendFeedback($session_id, $feedback) {
		if(!in_array($feedback, $this->possible_feedback)) throw new Exception('Invalid Feedback Supplied: '.$feedback, 66421);
		$params = array(
				'session_id' => (string) $session_id,
				'feedback' => (string) $feedback
			);
		$response = $this->doCall('sendFeedback', $params);
		if(!isset($response->params->param->value->boolean)) throw new Exception('Invalid Response In sendFeedback', 66420);
		return $response->params->param->value->boolean == '1' ? true : false;
	}

	public function getStatistics($type) {
		if(!in_array($type, $this->possible_stats)) throw new Exception('Invalid Type Supplied: '.$type, 66419);
		$response = $this->doCall('getStatistics', array('type'=>(string) $type));
		if(!isset($response->params->param->value->int)) throw new Exception('Invalid Response In getStatistics', 66418);
		return (int) $response->params->param->value->int;
	}

	public function getServerList() {
		$response = $this->doCall('getServerList', array(), $this->server_host);
		if(!isset($response->params->param->value->array->data->value)) throw new Exception('Invalid response in getServerList', 66417);
		$this->server_list = array();
		foreach($response->params->param->value->array->data->value AS $server) {
			$this->server_list[] = (string) $server->string;
		}
		if(empty($this->server_list)) {
			$this->server_list = $this->default_servers;
		}
		return $this->server_list;
	}

	public function getImageCaptcha($session_id = null) {
		$params = array();
		$ip = $this->getIP();
		if(!empty($ip)) $params['author_ip'] = (string) $ip;
		if(!empty($session_id)) $params['session_id'] = (string) $session_id;

		$response = $this->doCall('getImageCaptcha', $params);
		if(!isset($response->params->param->value->struct->member)) throw new Exception('Invalid response in getImageCaptcha', 66416);
		$return = array();
		foreach($response->params->param->value->struct->member AS $part) {
			$return[(string) $part->name] = (string) $part->value->string;
		}
		$return['html'] = '<img src="'.$return['url'].'" alt="Mollom Captcha" />';
		return $return;
	}

	public function getAudioCaptcha($session_id = null) {
		$params = array();
		$ip = $this->getIP();
		if(!empty($ip)) $params['author_ip'] = (string) $ip;
		if(!empty($session_id)) $params['session_id'] = (string) $session_id;

		$response = $this->doCall('getAudioCaptcha', $params);
		if(!isset($response->params->param->value->struct->member)) throw new Exception('Invalid response in getAudioCaptcha', 66415);
		$return = array();
		foreach($response->params->param->value->struct->member AS $part) {
			$return[(string) $part->name] = (string) $part->value->string;
		}
		$return['html'] = '<object type="audio/mpeg" data="'. $return['url'] .'" width="50" height="16">'."\n"
								."\t".'<param name="autoplay" value="false" />'."\n"
								."\t".'<param name="controller" value="true" />'."\n"
							.'</object>';
		return $return;
	}

	public function checkCaptcha($session_id, $solution) {
		$ip = $this->getIP();
		$params = array();
		$params['session_id'] = (string) $session_id;
		$params['solution'] = (string) $solution;
		if(!empty($ip)) $params['author_ip'] = (string) $ip;
		$response = $this->doCall('checkCaptcha', $params);
		if(!isset($response->params->param->value->boolean)) throw new Exception('Invalid response in checkCaptcha', 66414);
		return $response->params->param->value->boolean == '1' ? true : false;
	}

	/**
	 * Performs the checkContent call
	 * 
	 * @param array arguments for the call
	 * @throws exception for invalid responses
	 * @return array corresponding to result status from Mollom
	 */
	public function checkContent($args = array()) {
		$params = array();
		foreach($args AS $key => $value) {
			//first check if it's allowd
			if(!in_array($key, $this->content_args)) continue;
			$params[$key] = (string) $value;
		}
		if(empty($params)) throw new Exception('You must specify at least one valid argument to checkContent()', 66413);

		$ip = $this->getIP();
		if(!empty($ip)) $params['author_ip'] = (string) $ip;
		$response = $this->doCall('checkContent', $params);
		if(!isset($response->params->param->value->struct->member)) throw new Exception('Invalid response in checkContent', 66412);
		$return = array();
		foreach($response->params->param->value->struct->member AS $part) {
			$key = (string) $part->name;
			$value = null;
			switch($key) {
				case 'spam':
					$value = (string) $part->value->int;
					switch($value) {
						case '0':
							$return['spam'] = 'unknown';
							break;
						case '1':
							$return['spam'] = 'ham';
							break;
						case '2':
							$return['spam'] = 'spam';
							break;
						case '3':
							$return['spam'] = 'unsure';
							break;
					}
					break;
				case 'quality':
					$return['quality'] = (float) $part->value->double;
					break;
				case 'session_id':
					$return['session_id'] = (string) $part->value->string;
					break;
			}
		}
		return $return;
	}
	
	/**
	 * get IP of user
	 * 
	 * @return string ip address
	 */
	public function getIP() {
		if(!isset($_SERVER['REMOTE_ADDR'])) return null;
		// get ip
		$ipAddress = $_SERVER['REMOTE_ADDR'];
		if($this->reverse_proxy) {
			if(isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
				if(!empty($this->allowed_reverse_proxy_addresses) && in_array($ipAddress, $this->allowed_reverse_proxy_addresses, true)) {
					return array_pop(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']));
				}
   			}
			if(isset($_SERVER['HTTP_X_CLUSTER_CLIENT_IP'])) return $_SERVER['HTTP_X_CLUSTER_CLIENT_IP'];
		}
		// fallback
		return $ipAddress;	
	}

	/**
	 * Build a value for XMLRPC call
	 * 
	 * @param mixed php value to send
	 * @return XML representation of value
	 */
	private function buildValue($value) {
		if(is_array($value)) {
			$struct = '<value>'."\n";
			$struct .= '	<struct>'."\n";
			// loop array
			foreach ($value as $key => $value) $struct .= str_replace("\n", '', '<member>'. "\n" .'<name>'. $key .'</name>'. $this->buildValue($value) .'</member>') . "\n";
			$struct .= '	</struct>'."\n";
			$struct .= '</value>'."\n";
			// return
			return $struct;		
		} elseif(is_string($value)) {
			$value = htmlspecialchars($value, ENT_QUOTES, 'ISO-8859-15');
			return '<value><string>'. $value .'</string></value>'."\n";	
		} else {
			return '<value>'.$value.'</value>';
		}
	}

	/**
	 * Checks current settings
	 * 
	 * @param mixed Server to send to (parsable by prepareServer())
	 * @throws Exception for improper settings
	 */
	private function checkSettings($server = null) {
		if(empty($this->private_key)) {
			throw new Exception('Mollom must have $private_key populated', 66410);
		}
		if(empty($this->public_key)) {
			throw new Exception('Mollom must have $public_key populated', 66409);
		}
		if(empty($this->server_list) && empty($server)) {
			$this->server_list = $this->default_servers;
		}
	}

	/**
	 * Execute a XMLRPC call
	 * 
	 * @param string Method to call on remote server
	 * @param array Additional parameters to send with request
	 * @param mixed Server to send to (parsable by prepareServer())
	 * @return object simplexml element instance
	 */
	private function doCall($method, $params = array(), $server = null) {
		$this->checkSettings($server);
		if(!in_array($method, $this->methods)) {
			throw new InvalidArgumentException('Method argument contains illegal method: '.$method, 66408);
		}
		if(empty($server)) {
			$server = 0;
		}

		$time = gmdate("Y-m-d\TH:i:s.\\0\\0\\0O", time());
		$nonce = md5(time());

		$hash = base64_encode(
					pack("H*", sha1((str_pad($this->private_key, 64, chr(0x00)) ^ (str_repeat(chr(0x5c), 64))) .
					pack("H*", sha1((str_pad($this->private_key, 64, chr(0x00)) ^ (str_repeat(chr(0x36), 64))) .
					$time . ':'. $nonce .':'. $this->private_key))))
				);	

		$params['public_key'] = $this->public_key;
		$params['time'] = $time;
		$params['hash'] = $hash;
		$params['nonce'] = $nonce;

		$body = '<?xml version="1.0"?>
<methodCall>
	<methodName>mollom.'.$method.'</methodName>
	<params>
		<param>
			'.$this->buildValue($params).'
		</param>
	</params>
</methodCall>';
		return $this->send($body, $server);
	}

	/**
	 * Returns prepared server name given mixed input
	 * 
	 * @param mixed string server, or int representing index of internal server list
	 * @return string parsable server uri
	 */
	private function prepareServer($server) {
		if(is_int($server)) {
			$server = isset($this->server_list[$server]) ? $this->server_list[$server] : '';
		}
		$info = parse_url($server);
		if(!isset($info['host'])) {
			throw new Exception('Invalid Host Detected');
		}
		if(!isset($info['port'])) {
			$info['port'] = 80;
		}
		return $info;
	}
		
	/**
	 * This method proxies to sendFSock or sendCurl depending on server config, and processes it
	 * 
	 * @param string Post body to send
	 * @param mixed String server to send to, or number of internal server list to send to
	 * @param mixed number on the internal server list of current server
	 * @return object simplexml element instance
	 */			
	private function send($body, $server, $counter = 0) {
		if(function_exists('curl_init')) {
			$return = $this->sendCurl($body, $server, $counter);
		} else {
			$return = $this->sendFSock($body, $server, $counter);
		}
		return $this->processResponse($return, array($body, $counter));
	}
	
	/**
	 * Internal send mechanism for fsockopen enabled servers
	 * 
	 * @param string Post body to send
	 * @param mixed String server to send to, or number of internal server list to send to
	 * @param mixed number on the internal server list of current server
	 * @return string raw text response
	 */
	private function sendFSock($body, $server, $counter = 0, $path = '') {
		$info = $this->prepareServer($server);
		$path = empty($path) ? '/'.$this->api_version : $path;
		$request = "POST ".$path." HTTP/1.0\r\n";
		$request .= "Content-length: ".strlen($body)."\r\n";
		$request .= "Connection: close\r\n";
		$request .= "Content-type: application/x-www-form-urlencoded\r\n";
		$request .= "Useragent: ".$this->user_agent."\r\n";
		$request .= "\r\n";
		$request .= $body;
		if(isset($info['scheme']) && $info['scheme'] == 'https') {
			$info['host'] = 'ssl://'. $info['host'];
		}
		
		$errno = null;
		$errmsg = null;
		$f = @fsockopen($info['host'], $info['port'], $errno, $errmsg, $this->timeout);
		if($f) {
			stream_set_timeout($f, $this->timeout);
			fwrite($f, $request);
			$return = '';
			while($line = fgets($f)) {
				$return .= $line;
			}
			$info = stream_get_meta_data($f);
			fclose($f);
			if($info['timed_out']) {
				//Timeout Sending/Recieving!
				$counter++;
				if(isset($this->server_list[$counter])) {
					return $this->sendFSock($body, $counter, $counter);
				} else {
					throw new Exception('No more servers available, try to increase the timeout.', 66405);
				}
			} else {
				//make sure we didn't get a 30x redirect header
				if(preg_match('#^Location:(.*?)$#i', $return, $match)) {
					//we have it, so parse the new url to go to, and go there!
					$url = parse_url($match[1]);
					$newserver = isset($url['host']) ? $url['host'] : $server;
					$newpath = isset($url['path']) ? $url['path'] : $path;
					if($newpath == $path && $newserver == $server) {
						throw new Exception('Infinite Redirect Loop Detected.  Aborting', 66407);
					}
					return $this->sendFSock($body, $newserver, $counter, $newpath);
				}
			}
			return trim($return);
		} else {
			$counter++;
			if(isset($this->server_list[$counter])) {
				return $this->sendFSock($body, $counter, $counter);
			} else {
				throw new Exception('No more servers available, try to increase the timeout.', 66405);
			}				
		}		
	}

	/**
	 * Internal send mechanism for curl enabled servers
	 * 
	 * @param string Post body to send
	 * @param mixed String server to send to, or number of internal server list to send to
	 * @param mixed number on the internal server list of current server
	 * @return string raw text response
	 */
	private function sendCurl($body, $server, $counter = 0) {
		$curl = curl_init();
		$info = $this->prepareServer($server);
		$info['host'] = (isset($info['scheme']) ? $info['scheme'] : 'http'). '://'. $info['host'];
		
		curl_setopt($curl, CURLOPT_USERAGENT, $this->user_agent);
		curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_0);
		curl_setopt($curl, CURLOPT_POST, true);
		curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
		curl_setopt($curl, CURLOPT_HEADER, true);
		curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
		curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, $this->timeout);
		curl_setopt($curl, CURLOPT_TIMEOUT, $this->timeout);
		curl_setopt($curl, CURLOPT_PORT, $info['port']);
		curl_setopt($curl, CURLOPT_URL, $info['host'].'/'. $this->api_version);
		curl_setopt($curl, CURLOPT_POSTFIELDS, $body);

		$response = curl_exec($curl);
		$errorNum = (int) curl_errno($curl);
		$errorMsg = curl_error($curl);
		curl_close($curl);
		if($response === false || $errorNum != 0) {
			$counter++;
			if(isset($this->server_list[$counter])) {
				return $this->sendCurl($body, $counter, $counter);
			} else {
				throw new Exception('No more servers available, try to increase the timeout.', 66405);
			}
		}
		return $response;
	}
	
	/**
	 * Process the response, check for errors, and proceed
	 * 
	 * @param string response to process
	 * @param array info to resend the request on error
	 * @return object simplexml object of parsed response
	 */
	private function processResponse($response, $resend) {
		$parts = explode("\r\n\r\n", $response);
		if(!isset($parts[0]) && !isset($parts[1])) {
			return $this->send($resend[0], $resend[1]++, $resend[1]++);
		}
		$headers = array_shift($parts);
		$body = implode('', $parts);
		$validHeaders = array('HTTP/1.0 200', 'HTTP/1.1 200');
		if(!in_array(substr($headers, 0, 12), $validHeaders)) {
			return $this->send($resend[0], $resend[1]++, $resend[1]++);
		}
		$xml = @simplexml_load_string($body);
		if($xml === false) {
			return $this->send($resend[0], $resend[1]++, $resend[1]++);
		}

		if(isset($xml->fault)) {
			$code = isset($xml->fault->value->struct->member[0]->value->int) ? $xml->fault->value->struct->member[0]->value->int : 'unknown';
			$message = isset($xml->fault->value->struct->member[1]->value->string) ? $xml->fault->value->struct->member[1]->value->string : 'unknown';
			switch($code) {
				case 1100: //Server list outdated
					//handle update of servers
					$this->getServerList();
					$this->servers_updated = true;
				case 1200: //Server too busy
					if(empty($this->server_list)) $this->getServerList();
					return $this->send($resend[0], $resend[1]++, $resend[1]++);
					break;
				default:
					throw new Exception('[Error '.$code.'] '.$message, 66401);
					break;
			}
		}
		return $xml;
	}

}

