<?php
// Protection against direct access
defined('AKEEBAENGINE') or die();

/**
 * SugarSync PHP API class for Akeeba Engine
 * 
 * @copyright Copyright (c)2012-2014 Nicholas K. Dionysopoulos / AkeebaBackup.com
 * @license GNU General Public License version 3 or, at your option, any later version
 */
class AEUtilSugarsync
{
	/** @var string The URL to the SugarSync API endpoint */
	private $apiURL = 'https://api.sugarsync.com';
	
	private $userAgent = 'AkeebaEngine/';
	
	/** @var string The developer's access key */
	private $accessKey = '';
	
	/** @var string The developer's private key */
	private $privateKey = '';
	
	/** @var string The user's email address */
	private $userEmail = '';
	
	/** @var string The user's password */
	private $userPassword = '';
	
	/** @var string The API access token */
	private $accessToken = null;
	
	/** @var string The ID of the authenticated SugarSync user */
	private $userID = null;

	/**
	 * Public constructor. Remember to pass a configuration array with the keys
	 * access, private, email and password. Read the code for more info.
	 * 
	 * @param array $config The configuration array
	 * @throws AEUtilSugarsyncException 
	 */
	public function __construct($config = array())
	{
		// Fetch the configuration parameters
		$this->accessKey		= array_key_exists('access', $config) ? $config['access'] : '';
		$this->privateKey		= array_key_exists('private', $config) ? $config['private'] : '';
		$this->userEmail		= array_key_exists('email', $config) ? $config['email'] : '';
		$this->userPassword		= array_key_exists('password', $config) ? $config['password'] : '';
		
		// Update the user agent with the version of the engine
		$this->userAgent .= AKEEBA_VERSION;
	}
	
	/**
	 * Is this object connected (authenticated) to SugarSync yet?
	 * 
	 * @return bool
	 */
	public function isConnected()
	{
		return !empty($this->accessToken) && !empty($this->userID);
	}
	
	/**
	 * (Re-)Connect to SugarSync
	 * 
	 * @param array $config Optional override for configuration parameters
	 * @throws AEUtilSugarsyncException 
	 */
	public function connect($config = array())
	{
		// Apply configuration overrides
		if(array_key_exists('access', $config)) {
			$this->accessKey		= $config['access'];
		}
		if(array_key_exists('private', $config)) {
			$this->privateKey		= $config['private'];
		}
		if(array_key_exists('email', $config)) {
			$this->userEmail		= $config['email'];
		}
		if(array_key_exists('password', $config)) {
			$this->userPassword		= $config['password'];
		}
		
		// Check that all configuration parameters are in place
		if(empty($this->accessKey)) {
			throw new AEUtilSugarsyncException('You must set the developer access key');
		}
		if(empty($this->privateKey)) {
			throw new AEUtilSugarsyncException('You must set the developer private key');
		}
		if(empty($this->userEmail)) {
			throw new AEUtilSugarsyncException('You must set the user\'s email address');
		}
		if(empty($this->userPassword)) {
			throw new AEUtilSugarsyncException('You must set the user\'s password');
		}
		
		$xml  = '<?xml version="1.0" encoding="UTF-8" ?>'."\n";
		$xml .= '<authRequest>'."\n";
		$xml .= '<username>'.utf8_encode($this->userEmail).'</username>'."\n";
		$xml .= '<password>'.utf8_encode($this->userPassword).'</password>'."\n";
		$xml .= '<accessKeyId>'.utf8_encode($this->accessKey).'</accessKeyId>'."\n";
		$xml .= '<privateAccessKey>'.utf8_encode($this->privateKey).'</privateAccessKey>'."\n";
		$xml .= '</authRequest>';
		
		$descriptor = array(
			'method'			=> 'authorization',
			'verb'				=> 'POST',
			'data'				=> $xml,
			'auth'				=> false,
			'return_headers'	=> true,
		);

		$this->accessToken = null;
		$this->userID = null;
		
		$ret = $this->apiCall($descriptor);
		
		$result = $ret['result'];

		// Extract the token
		if (preg_match('/Location:(.*?)\r/i', $result, $m)) 
		{
			$this->accessToken = $m[1];
		}
		
		// Extract the user ID
		$userStart = strpos($result, '<user>') + 6;
		$userEnd = strpos($result, '</user>');
		$userURL = substr($result, $userStart, $userEnd - $userStart);
		$userParts = explode('/', $userURL);
		$this->userID = array_pop($userParts);
	}
	
	/**
	 * Get a list of the top-level sync folders of the user's account
	 * 
	 * @staticvar array|null $folders Caches the sync folders list
	 * @return array Sync folders as a display_name => internal_ID hash array
	 */
	public function getSyncFolders()
	{
		static $folders = null;
		
		if(is_null($folders))
		{
			if(!$this->isConnected()) {
				$this->connect();
			}

			$descriptor = array(
				'method'			=> 'user/'.$this->userID.'/folders/contents',
				'verb'				=> 'GET',
			);

			$ret = $this->apiCall($descriptor);

			$xml = $ret['result'];
			$dom = new DOMDocument('1.0', 'UTF-8');
			$dom->loadXML($xml);
			$collections = $dom->getElementsByTagName('collection');
			$folders = array();
			for($i = 0; $i < $collections->length; $i++)
			{
				$item = $collections->item($i);
				$name = $item->getElementsByTagName('displayName')->item(0)->nodeValue;
				$ref = $item->getElementsByTagName('ref')->item(0)->nodeValue;
				$refParts = explode('/', $ref);
				$id = array_pop($refParts);
				$folders[$name] = $id;
			}
			unset($dom);
		}
		
		return $folders;
	}
	
	/**
	 * Resolves a folder path to a folder ID
	 * 
	 * @staticvar array $mappedFolders Cache of folder names to folder IDs
	 * 
	 * @param string $folder The path to the folder
	 * @param bool $createMissing Should I create any folders which do not exist along the way?
	 * @return string The folder ID
	 */
	private function resolveFolder($folder, $createMissing = false)
	{
		static $mappedFolders = array();
		
		if(!array_key_exists($folder, $mappedFolders)) {
			// Break the folder into bits and pieces
			$folderParts = explode('/', $folder);
			
			// First, let's fetch a list of top-level sync folders
			$syncFolders = $this->getSyncFolders();
			
			// Is our top-level folder really a top-level folder?
			if(!array_key_exists($folderParts[0], $syncFolders)) {
				// Treason! The user did not use a top-level folder!
				if(array_key_exists('Magic Briefcase', $syncFolders)) {
					// OK, let's use the user's "Magic Briefcase"
					array_unshift($folderParts, 'Magic Briefcase');
				} else {
					// This should normally never, ever be executed
					$randomFolder = array_shift($syncFolders);
					array_unshift($syncFolders, $randomFolder);
					array_unshift($folderParts, 'Magic Briefcase');
				}
			}
			
			// Get the ID of the top-level folder
			$toplevelFolder = array_shift($folderParts);
			$toplevelID = $syncFolders[$toplevelFolder];
			
			$folderID = $this->folderReduce($folderParts, $toplevelID, $createMissing);
			
			$mappedFolders[$folder] = $folderID;
		}
		
		return $mappedFolders[$folder];
	}
	
	/**
	 * Recursive internal function to reduce a stack of path parts to an ID.
	 * Used by resolveFolder().
	 * 
	 * @param array $stack Stack of path parts to resolve
	 * @param string $id Folder ID relative to which I should resolve the stack
	 * @param bool $createMissing Should I create missing folders along the way
	 * @return string The folder ID to which the stack resolves
	 * 
	 * @throws AEUtilSugarsyncException 
	 */
	private function folderReduce($stack, $id, $createMissing = false)
	{
		if(empty($stack)) {
			return $id;
		}
		
		$search = array_shift($stack);
		
		$descriptor = array(
			'method'			=> 'folder/'.$id.'/contents?type=folder',
			'verb'				=> 'GET',
		);
		$ret = $this->apiCall($descriptor);
		
		$xml = $ret['result'];
		$dom = new DOMDocument('1.0', 'UTF-8');
		$dom->loadXML($xml);
		$collections = $dom->getElementsByTagName('collection');
		$folders = array();
		for($i = 0; $i < $collections->length; $i++)
		{
			$item = $collections->item($i);
			$name = $item->getElementsByTagName('displayName')->item(0)->nodeValue;
			$ref = $item->getElementsByTagName('ref')->item(0)->nodeValue;
			$refParts = explode('/', $ref);
			$xid = array_pop($refParts);
			$folders[$name] = $xid;
		}
		unset($dom);
		
		if(array_key_exists($search, $folders)) {
			// Folder found; recurse
			return $this->folderReduce($stack, $folders[$search], $createMissing);
		} else {
			// The folder was not found
			if($createMissing) {
				$newId = $this->createFolder($id, $search);
				return $this->folderReduce($stack, $newId, $createMissing);
			} else {
				throw new AEUtilSugarsyncException("The requested folder could not be located in your SugarSync account");
			}
		}
	}
	
	/**
	 * Creates a new folder and returns its ID
	 * 
	 * @param string $container Container folder's ID or path
	 * @param string $newFoldername The display name of the new folder
	 * 
	 * @return string The ID of the created folder
	 * 
	 * @throws AEUtilSugarsyncException
	 */
	public function createFolder($container, $newFoldername)
	{
		if(substr($container,0,4) != ':sc:') {
			$container = $this->resolveFolder($container);
		}
		
		$xml  = '<?xml version="1.0" encoding="UTF-8" ?>';
		$xml .= '<folder><displayName>'.utf8_encode($newFoldername).'</displayName></folder>';
		
		$descriptor = array(
			'method'			=> 'folder/'.$container,
			'verb'				=> 'POST',
			'data'				=> $xml,
			'return_headers'	=> true
		);
		
		$ret = $this->apiCall($descriptor);
		$result = $ret['result'];

		// Extract the URL
		if (preg_match('/Location:(.*?)\r/i', $result, $m)) {
			$url = $m[1];
		} else {
			$url = '/';
		}
		
		$urlParts = explode('/', $url);
		return array_pop($urlParts);
	}
	
	/**
	 * Lists all subfolders of a folder
	 * 
	 * @param string $container Folder ID or path to list
	 * 
	 * @return array Hashed array, folder name => folder ID
	 * 
	 * @throws AEUtilSugarsyncException
	 */
	public function getFolders($container)
	{
		if(substr($container,0,4) != ':sc:') {
			$container = $this->resolveFolder($container);
		}
		
		$descriptor = array(
			'method'			=> 'folder/'.$container.'/contents?type=folder',
			'verb'				=> 'GET',
		);
		$ret = $this->apiCall($descriptor);
		
		$xml = $ret['result'];
		$dom = new DOMDocument('1.0', 'UTF-8');
		$dom->loadXML($xml);
		$collections = $dom->getElementsByTagName('collection');
		$folders = array();
		for($i = 0; $i < $collections->length; $i++)
		{
			$item = $collections->item($i);
			$name = $item->getElementsByTagName('displayName')->item(0)->nodeValue;
			$ref = $item->getElementsByTagName('ref')->item(0)->nodeValue;
			$refParts = explode('/', $ref);
			$xid = array_pop($refParts);
			$folders[$name] = $xid;
		}
		unset($dom);
		
		return $folders;
	}
	
	/**
	 * Lists all files of a folder
	 * 
	 * @param string $container Folder ID or path to list
	 * 
	 * @return array Hashed array, file name => file ID
	 * 
	 * @throws AEUtilSugarsyncException
	 */
	public function getFiles($container)
	{
		if(substr($container,0,4) != ':sc:') {
			$container = $this->resolveFolder($container);
		}
		
		$descriptor = array(
			'method'			=> 'folder/'.$container.'/contents?type=file',
			'verb'				=> 'GET',
		);
		$ret = $this->apiCall($descriptor);
		
		$xml = $ret['result'];

		$dom = new DOMDocument('1.0', 'UTF-8');
		$dom->loadXML($xml);
		$collections = $dom->getElementsByTagName('file');
		$files = array();
		for($i = 0; $i < $collections->length; $i++)
		{
			$item = $collections->item($i);
			$name = $item->getElementsByTagName('displayName')->item(0)->nodeValue;
			$ref = $item->getElementsByTagName('ref')->item(0)->nodeValue;
			$refParts = explode('/', $ref);
			$xid = array_pop($refParts);
			$files[$name] = $xid;
		}
		unset($dom);
		
		return $files;
	}
	
	/**
	 * Uploads a file, overwriting a file by the same name if one exists.
	 * 
	 * @param string $container Folder ID, or path to the folder, or full path to the file
	 * @param string|null $fileName Name of the remote file, or null if a full path is provided in $container
	 * @param string $localFile Full path to the local file to upload
	 * 
	 * @return boolean True on success
	 * 
	 * @throws AEUtilSugarsyncException
	 */
	public function uploadFile($container, $fileName = null, $localFile = null)
	{
		if(substr($container,0,4) != ':sc:') {
			if(empty($fileName)) {
				$pathParts = explode('/', $container);
				$fileName = array_pop($pathParts);
				$container = implode('/', $pathParts);
			}
			
			$container = $this->resolveFolder($container, true);
		}
		
		// First check if the file already exists
		$files = $this->getFiles($container);
		
		if(!array_key_exists($fileName, $files)) {
			$fileID = $this->createFile($container, $fileName);
		} else {
			$fileID = $files[$fileName];
		}
		
		$descriptor = array(
			'method'			=> 'file/'.$fileID.'/data',
			'verb'				=> 'PUT',
			'data'				=> $localFile
		);
		$ret = $this->apiCall($descriptor);
		
		return true;
	}
	
	/**
	 * Creates an (empty) file
	 * 
	 * @param string $container Folder ID, or path to the folder, or full path to the file
	 * @param string|null $fileName Name of the remote file, or null if a full path is provided in $container
	 * 
	 * @return string The file ID
	 * 
	 * @throws AEUtilSugarsyncException
	 */
	public function createFile($container, $fileName = null, $mimeType = 'application/octet-stream')
	{
		if(substr($container,0,4) != ':sc:') {
			if(empty($fileName)) {
				$pathParts = explode('/', $container);
				$fileName = array_pop($pathParts);
				$container = implode('/', $pathParts);
			}
			$container = $this->resolveFolder($container, true);
		}
		
		// First check if the file already exists
		$files = $this->getFiles($container);

		if(array_key_exists($fileName, $files)) {
			return $files[$fileName];
		}
		
		$xml  = '<?xml version="1.0" encoding="UTF-8" ?>';
		$xml .= '<file>';
		$xml .= '<displayName>'.utf8_encode($fileName).'</displayName>';
		$xml .= '<mediaType>'.$mimeType.'</mediaType>';
		$xml .= '</file>';
		
		$descriptor = array(
			'method'			=> 'folder/'.$container,
			'verb'				=> 'POST',
			'data'				=> $xml,
			'return_headers'	=> true,
		);
		$ret = $this->apiCall($descriptor);
		
		$result = $ret['result'];

		// Extract the URL
		if (preg_match('/Location:(.*?)\r/i', $result, $m)) {
			$url = $m[1];
		} else {
			$url = '/';
		}
		
		$urlParts = explode('/', $url);
		return array_pop($urlParts);
	}
	
	/**
	 * Downloads a file
	 * 
	 * @param string $container A folder ID, or a folder path or a full path to the file to download
	 * @param string|null $file Remote filename or null if $container is a full path
	 * @param string|null $localFile Full path to the local file to write the data. If null, the raw file data will be returned by this method.
	 */
	public function downloadFile($container, $file, $localFile = null)
	{
		if(substr($container,0,4) != ':sc:') {
			if(empty($file)) {
				$pathParts = explode('/', $container);
				$file = array_pop($pathParts);
				$container = implode('/', $pathParts);
			}
			$container = $this->resolveFolder($container);
		}
		
		// First check if the file already exists
		$files = $this->getFiles($container);
		
		if(array_key_exists($file, $files)) {
			$fileID = $files[$file];
		} else {
			throw new AEUtilSugarsyncException("File not found");
		}
		
		$descriptor = array(
			'method'		=> 'file/'.$fileID.'/data',
			'verb'			=> 'GET',
			'data'			=> $localFile
		);
		$ret = $this->apiCall($descriptor);
		
		if(empty($localFile)) {
			return $ret['result'];
		}
	}
	
	public function deleteFile($container, $file = null)
	{
		if(substr($container,0,4) != ':sc:') {
			if(empty($file)) {
				$pathParts = explode('/', $container);
				$file = array_pop($pathParts);
				$container = implode('/', $pathParts);
			}
			$container = $this->resolveFolder($container);
		}
		
		// First check if the file already exists
		$files = $this->getFiles($container);
		
		if(array_key_exists($file, $files)) {
			$fileID = $files[$file];
		} else {
			throw new AEUtilSugarsyncException("File not found");
		}
		
		$descriptor = array(
			'method'		=> 'file/'.$fileID,
			'verb'			=> 'DELETE',
		);
		$ret = $this->apiCall($descriptor);
		
		return true;
	}
	
	/**
	 * Calls SugarSync's API and returns the results
	 * 
	 * @param array $descriptor An array describing the API call you want to make
	 * @return array
	 * @throws AEUtilSugarsyncException 
	 */
	private function apiCall($descriptor = array())
	{
		// Get data from descriptor
		$suffix	= array_key_exists('method', $descriptor) ? $descriptor['method'] : '';
		$data	= array_key_exists('data', $descriptor) ? $descriptor['data'] : '';
		$verb	= array_key_exists('verb', $descriptor) ? $descriptor['verb'] : 'GET';
		$auth	= array_key_exists('auth', $descriptor) ? $descriptor['auth'] : true;
		$heads	= array_key_exists('headers', $descriptor) ? $descriptor['headers'] : array();
		$returnHeaders	= array_key_exists('return_headers', $descriptor) ? $descriptor['return_headers'] : false;
		$shutup = array_key_exists('shutup', $descriptor) ? $descriptor['shutup'] : false;
		
		// Make sure the HTTP verb is a supported one
		if(!in_array($verb, array('GET','POST','PUT','DELETE'))) {
			$verb = 'GET';
		}
		
		// Calculate the URL
		$url = $this->apiURL.'/'.$suffix;
		
		// Create the HTTP headers array
		$headers = array(
			'Expect:'
		);
		$headers = array_merge($headers, $heads);
		
		// Handle extra headers for authorised API calls
		if($auth && !$this->isConnected()) {
			$this->connect();
		}
		if($auth) {
			$headers[] = 'Authorization: '.$this->accessToken;
		}
		
		$ch = curl_init($url);
		curl_setopt($ch,		CURLOPT_USERAGENT,		$this->userAgent);
		@curl_setopt($ch,		CURLOPT_FOLLOWLOCATION,	true);
		curl_setopt($ch,		CURLOPT_RETURNTRANSFER,	true);
		if(defined('AKEEBA_CACERT_PEM')) {
			@curl_setopt($ch,	CURLOPT_CAINFO, AKEEBA_CACERT_PEM);
			curl_setopt($ch,	CURLOPT_SSL_VERIFYHOST, (stristr(PHP_OS, 'WIN') ? false : true));
			curl_setopt($ch,	CURLOPT_SSL_VERIFYPEER, (stristr(PHP_OS, 'WIN') ? false : true));
		} else {
			curl_setopt($ch,	CURLOPT_SSL_VERIFYHOST, false);
			curl_setopt($ch,	CURLOPT_SSL_VERIFYPEER, false);
		}
		
		$fp = null;
		switch($verb)
		{
			case 'POST':
				curl_setopt($ch,CURLOPT_POSTFIELDS,		$data);
				curl_setopt($ch,CURLOPT_POST,			true);
				$headers[] = 'Content-Type: application/xml; charset=UTF-8';
				$headers[] = 'Content-Length: '.strlen($data);
				break;
			
			case 'PUT':
				if(is_file($data) && is_readable($data)) {
					$headers[] = 'Content-Length: '.filesize($data);
					$fp = fopen($data, 'rb');
					curl_setopt($ch, CURLOPT_PUT, true);
					curl_setopt($ch, CURLOPT_INFILE, $fp);
					curl_setopt($ch, CURLOPT_INFILESIZE, filesize($data));
				} else {
					throw new AEUtilSugarsyncException("$data is not readable; can not upload to SugarSync");
				}
				break;
			
			case 'DELETE':
				curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
				break;
			
			case 'GET':
				if(!empty($data)) {
					$fp = fopen($data, 'wb');
					curl_setopt($ch, CURLOPT_FILE, $fp);
				}
				curl_setopt($ch, CURLOPT_POST, false);
				break;
		}

		curl_setopt($ch,		CURLINFO_HEADER_OUT,	true);
		curl_setopt($ch,		CURLOPT_HTTPHEADER,		$headers);
		if($returnHeaders) {
			curl_setopt($ch,	CURLOPT_HEADER,			true);
		}
		
		$result = curl_exec($ch);
		$info = curl_getinfo($ch);
		$errno = curl_errno($ch);
		$error = curl_error($ch);
		
		@curl_close($ch);
		
		if(!is_null($fp)) {
			fclose($fp);
		}
		
		if(!$shutup && ($errno != 0)) {
			throw new AEUtilSugarsyncException("Network error [$errno]: $error");
		}
		
		$ret = array(
			'result'	=> $result,
			'info'		=> $info,
			'errno'		=> $errno,
			'error'		=> $error,
		);
		
		$http_code = $info['http_code'];
		
		if($shutup || (($http_code >= 200) && ($http_code <= 299))) {
			return $ret;
		} elseif($http_status == 400) {
			throw new AEUtilSugarsyncException("HTTP Error [$http_code]: Required information was not provided to SugarSync");
		} elseif($http_status == 401) {
			throw new AEUtilSugarsyncException("HTTP Error [$http_code]: The credentials were rejected by SugarSync. Check your email and password.");
		} elseif($http_status == 403) {
			throw new AEUtilSugarsyncException("HTTP Error [$http_code]: Failed authentication.");
		} elseif($http_status == 404) {
			throw new AEUtilSugarsyncException("HTTP Error [$http_code]: Not found.");
		} else {
			throw new AEUtilSugarsyncException("HTTP Error [$http_code]: Server Error; SugarSync's API service may be down or experiencing a technical problem");
		}
	}
}

class AEUtilSugarsyncException extends Exception {};