<?php

/**
 * THIS SOFTWARE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR IMPLIED
 * WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF
 * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
 *
 * @package fusionLib
 * @copyright Copyright (c) 2007 - 2013 fusionLib. All rights reserved.
 * @link http://fusionlib.com
 */

/**
 * flFtpClient Class
 *
 * Class to implement basic FTP client functionality, only passive mode is supported.
 *
 * RFC959 http://www.w3.org/Protocols/rfc959/ or http://www.ietf.org/rfc/rfc959.txt
 *
 * @package fusionLib
 */
class flFtpClient {

	/**#@+
	 * Transfer method.
	 */
	const AUTO = 0;
	const BINARY = 1;
	const ASCII = 2;
	/**#@-*/

	/**
	 * The server to connect to.
	 *
	 * @var string
	 */
	private $server;

	/**
	 * The user to authenticate with.
	 *
	 * @var string
	 */
	private $user;

	/**
	 * The password for the user.
	 *
	 * @var string
	 */
	private $password;

	/**
	 * The port to use for the connection.
	 *
	 * @var int
	 */
	private $port;

	/**
	 * The command socket.
	 *
	 * @var resource
	 */
	private $sock = null;

	/**
	 * Reply data.
	 *
	 * @var string
	 */
	private $replyData = '';

	/**
	 * Construct a new FTP client object and set the server information.
	 *
	 * @param string $server The FTP server to connect to.
	 * @param string $user The name of the user to authenticate with.
	 * @param string $password The password for the above user.
	 * @param int $port The port to connect to on the server.
	 */
	function __construct($server, $user = 'anonymous', $password = 'nobody@nobody.com', $port = 21) {
		$this->server = $server;
		$this->user = $user;
		$this->password = $password;
		$this->port = $port;
	}

	/**
	 * Internal function to get the command reply from the server.
	 *
	 * @return int The reply code, if a connection fails then the code 0 is returned.
	 */
	private function _getReply() {
		// Read from the server and extract just the code
		$prevCont = true; // continuation lines can have no code at the start, OSX 10.x.x
		$r = array();
		do {
			// Get the data
			$data = trim(@fread($this->sock, 4096), "\n");

			if($data) {
				// Break into lines
				$lines = explode("\n", $data);
				foreach($lines as $l) {
					if(!preg_match('/(\d{3})([ -]).*/', $l, $r) && !$prevCont)
						return 0;
				}
			}
			else
				return 0;

			$prevCont = $r[2] && $prevCont == '-' ? true : false;
		} while($r[2] == '-');

		$this->replyData = $data;

		return (int)$r[1];
	}

	/**
	 * Internal function to create a passive data connection to the current server.
	 *
	 * @return resource The socket to use for the data or null if the connection can't be created.
	 */
	private function _openPasv() {
		// Send command
		fwrite($this->sock, "PASV\n");

		// Get the reply and expand it
		$r = array();
		preg_match('/(\d{3})\s+.*\((\d{1,3}),(\d{1,3}),(\d{1,3}),(\d{1,3}),(\d{1,3}),(\d{1,3})\)/', trim(@fread($this->sock, 4096)), $r);
		if(count($r) != 8 || $r[1] != 227)
			return null;

		// Open a socket
		$errnum = 0;
		$errstr = '';
		if(!($dsock = fsockopen($r[2] . '.' . $r[3] . '.' . $r[4] . '.' . $r[5], ($r[6] * 256) + $r[7], $errnum, $errstr, 30)))
			return null;

		// Return the data socket
		return $dsock;
	}

	/**
	 * Internal routine to close the give data socket.
	 *
	 * @param resource $socket The data socket to close.
	 * @param bool $abort Flags that the close is because of an aborted operation,
	 * therefore the status code should be ignored.
	 */
	private function _closePasv($socket, $abort = false) {
		if(is_resource($socket)) {
			// Close the socket
			fclose($socket);

			// Get the ACK from the server
			if(!$abort)
				$this->_getReply(); // 226
		}
	}

	/**
	 * Get the transfer type based on the file extension.
	 *
	 * @param string $file The file name to guess the transfer type from.
	 * @return int The data type either ASCII or BINARY (the default)
	 */
	function getTransferType($file) {

		static $types = array('txt', 'php', 'js', 'css', 'htm', 'html', 'asp', 'xml');

		// If . present then assume a filename given
		if(strstr($file, '.') !== false) {
			$path = pathinfo($file);
			$file = $path['extension'];
		}

		return in_array($file, $types) ? self::ASCII : self::BINARY;
	}

	/**
	 * Connect to the FTP server, login and set to binary mode.
	 *
	 * @return bool Returns if the connection to the server was made, true, or failed, false.
	 */
	function connect() {

		// Open a socket
		$errno = 0;
		$errstr = '';
		if(!($this->sock = @fsockopen($this->server, $this->port, $errno, $errstr, 30)))
			return false;

		// Read the welcome message
		if($this->_getReply() != 220) {
			fclose($this->sock);
			$this->sock = null;
			return false;
		}

		// Send the user authentication
		fwrite($this->sock, "USER $this->user\n");
		if($this->_getReply() != 331) {
			$this->close();
			return false;
		}

		fwrite($this->sock, "PASS $this->password\n");
		if($this->_getReply() != 230) {
			$this->close();
			return false;
		}

		// Return connected
		return true;
	}

	/**
	 * Close the open connection.
	 */
	function close() {
		if(is_resource($this->sock)) {
			fwrite($this->sock, 'QUIT');
			fclose($this->sock);
			$this->sock = null;
		}
	}

	/**
	 * Make a new folder on the FTP server.
	 *
	 * @param string $path The path to the folder on the FTP server
	 * @return bool Flags if the directory was created or not.
	 */
	function createFolder($path) {
		fwrite($this->sock, "MKD $path\n");
		return $this->_getReply() != 257 ? false : true;
	}

	/**
	 * Delete a folder on the FTP server.
	 *
	 * @param string $path The path to the folder to delete.
	 * @return bool Flags if the directory was deleted or not.
	 */
	function deleteFolder($path) {
		fwrite($this->sock, "RMD $path\n");
		return $this->_getReply() != 250 ? false : true;
	}

	/**
	 * Rename a file or folder on the FTP server.
	 *
	 * @param string $from The file or folder to rename.
	 * @param string $to The new name of the file or folder.
	 * @return bool Flags if the file or folder was renamed or not.
	 */
	function rename($from, $to) {
		fwrite($this->sock, "RNFR $from\n");
		if($this->_getReply() != 350)
			return false;
		fwrite($this->sock, "RNTO $to\n");
		return $this->_getReply() != 250 ? false : true;
	}

	/**
	 * Delete a file from the FTP server.
	 *
	 * @param string $file The name of the file to delete.
	 * @return bool Flags if the file was deleted or not.
	 */
	function delete($file) {
		fwrite($this->sock, "DELE $file\n");
		return $this->_getReply() != 250 ? false : true;
	}

	/**
	 * Upload data to a file on the FTP server.
	 *
	 * @param string $file The file to store the data as.
	 * @param string $src The data to upload or the name of the file to upload.
	 * @param bool $fromFile true if $src is the name of the file to upload or false if it is the data.
	 * @param int $mode The transfer mode.
	 * @return bool Flags if the file was written or not.
	 */
	function putFile($file, $src, $fromFile = true, $mode = flFtpClient::AUTO) {

		// Set mode
		if($mode == self::AUTO) $mode = $this->getTransferType($file);
		fwrite($this->sock, 'TYPE ' . ($mode == self::BINARY ? 'I' : 'A') . "\n");
		if($this->_getReply() != 200)
			return false;

		// Open data connection
		if(!($dsock = $this->_openPasv()))
			return false; // Failed to open data connection

		// Send command
		fwrite($this->sock, "STOR $file\n");
		if(($r = $this->_getReply()) != 150 && $r != 125) {
			$this->_closePasv($dsock, true);
			return false;
		}

		// Write the data
		if($fromFile) { // From file so open then read & write
			if(($fp = @fopen($src, 'rb')) === false) {
				$this->_closePasv($dsock, true);
				return false;
			}

			while(!feof($fp)) {
				fwrite($dsock, fread($fp, 4096));
			}

			fclose($fp);
		}
		else // Just a string to write
			fwrite($dsock, $src);

		// Close the data connection
		$this->_closePasv($dsock);

		// Return worked
		return true;
	}

	/**
	 * Download a file from the FTP server.
	 *
	 * @param string $file The name of the file to download.
	 * @param string $store The name of the file to store the download to or null to keep it in memory.
	 * @param int $mode The transfer mode.
	 * @return mixed If $store is null then the file contents are returned, else true or false is returned.
	 */
	function getFile($file, $store = null, $mode = flFtpClient::AUTO) {

		// Set mode
		if($mode == self::AUTO) $mode = $this->getTransferType($file);
		fwrite($this->sock, 'TYPE ' . ($mode == self::BINARY ? 'I' : 'A') . "\n");
		if($this->_getReply() != 200)
			return false;

		// Open data connection
		if(!($dsock = $this->_openPasv()))
			return false; // Failed to open data connection

		// Send command
		fwrite($this->sock, "RETR $file\n");
		if(($r = $this->_getReply()) != 150 && $r != 125) {
			$this->_closePasv($dsock, true);
			return false;
		}

		// Get the data
		if($store !== null) { // Store to file
			if(($fp = @fopen($store, 'wb')) === false) {
				$this->_closePasv($dsock, true);
				return false;
			}

			while(!feof($dsock))
				fwrite($fp, fread($dsock, 4096));

			fclose($fp);

			$s = true;
		}
		else { // Return contents
			$s = '';
			while(!feof($dsock))
				$s .= fread($dsock, 4096);

			// Close the data connection
			$this->_closePasv($dsock);
		}

		return $s;
	}

	/**
	 * Set the currect folder for operations.
	 *
	 * @param string $path The new path to work in.
	 * @return bool Flags if the was changed or not.
	 */
	function changeFolder($path) {
		fwrite($this->sock, "CWD $path\n");
		return $this->_getReply() != 250 ? false : true;
	}

	/**
	 * Set the currect folder for operations to the parent folder.
	 *
	 * @return bool Flags if the was changed or not.
	 */
	function changeToParentFolder() {
		fwrite($this->sock, "CDUP\n");
		return $this->_getReply() != 250 ? false : true;
	}

	/**
	 * Set the permissions for the named file.
	 *
	 * @param string $file The file to change the permissions for.
	 * @param int $perms The octal permission numbers 0000 to 0777, e.g. 0664 = u,rw g,rw o,r
	 * - 1 = Execute
	 * - 2 = Write
	 * - 3 = Read
	 * @return bool Flags if the was changed or not.
	 */
	function chmod($file, $perms) {
		fwrite($this->sock, "SITE CHMOD $perms $file\n");
		return $this->_getReply() != 200 ? false : true;
	}

	/**
	 * Get the current folder.
	 *
	 * @return string The current folder or false.
	 */
	function currentFolder() {
		fwrite($this->sock, "PWD\n");

		$r = array();
		if($this->_getReply() != 257 || !preg_match('/\d{3}\s.*"([^"]*)"/', $this->replyData, $r))
			return false;

		return $r[1];
	}

	/**
	 * Test if the path is a folder.
	 *
	 * @param string $path The path to test.
	 * @return bool true if the path is a folder; else false.
	 */
	function isFolder($path) {
		if(($cwd = $this->currentFolder()) === false)
			return false;

		if(!$this->changeFolder($path))
			return false;

		$this->changeFolder($cwd);

		return true;
	}

	/**
	 * Get a list of the files and folders.
	 *
	 * @param string $path The path to get the file and folders from.
	 * @return array List of the files and folders within the given director.
	 */
	function listFiles($path = '.') {
		fwrite($this->sock, "TYPE A\n");
		if($this->_getReply() != 200)
			return false;

		// Open data connection
		if(!($dsock = $this->_openPasv()))
			return false; // Failed to open data connection

		// Send command
		fwrite($this->sock, "LIST $path\n");
		if(($r = $this->_getReply()) != 150 && $r != 125) {
			$this->_closePasv($dsock, true);
			return false;
		}

		// Get the data
		$s = '';
		while(!feof($dsock))
			$s .= fread($dsock, 4096);

		// Close the data connection
		$this->_closePasv($dsock);

		// Break the results to get a list of just the files and folders
		$fileList = array();
		$list = explode("\n", $s);
		foreach($list as $file) {
			$file = preg_split('/ /', $file, 20, PREG_SPLIT_NO_EMPTY);
			if(($c = count($file)) > 3) {
				$fileList[] = trim($file[$c - 2] == '->' ? $file[$c - 3] : $file[$c - 1]);
			}
		}

		return $fileList;
	}
}
