<?php /** * Akeeba Engine * * @package akeebaengine * @copyright Copyright (c)2006-2022 Nicholas K. Dionysopoulos / Akeeba Ltd * @license GNU General Public License version 3, or later */ namespace Akeeba\Engine\Util\Transfer; defined('AKEEBAENGINE') || die(); use Akeeba\Engine\Postproc\ProxyAware; use RuntimeException; /** * FTP transfer object, using cURL as the transport backend */ class FtpCurl extends Ftp implements TransferInterface { use ProxyAware; /** * Timeout for transferring data to the FTP server, default: 10 minutes * * @var integer */ protected $timeout = 600; /** * Should I ignore the IP returned by the server during Passive mode transfers? * * @var bool * * @see http://www.elitehosts.com/blog/php-ftp-passive-ftp-server-behind-nat-nightmare/ */ private $skipPassiveIP = true; /** * Should we enable verbose output to STDOUT? Useful for debugging. * * @var bool */ private $verbose = false; /** * Public constructor * * @param array $options Configuration options * * @throws RuntimeException */ public function __construct(array $options) { parent::__construct($options); if (isset($options['passive_fix'])) { $this->skipPassiveIP = $options['passive_fix'] ? true : false; } if (isset($options['verbose'])) { $this->verbose = $options['verbose'] ? true : false; } } /** * Save all parameters on serialization except the connection resource * * @return array */ public function __sleep() { return [ 'host', 'port', 'username', 'password', 'directory', 'ssl', 'passive', 'timeout', 'skipPassiveIP', 'verbose', ]; } /** * Test the connection to the FTP server and whether the initial directory is correct. This is done by attempting to * list the contents of the initial directory. The listing is not parsed (we don't really care!) and we do NOT check * if we can upload files to that remote folder. * * @throws RuntimeException */ public function connect() { $ch = $this->getCurlHandle($this->directory . '/'); curl_setopt($ch, CURLOPT_HEADER, 1); curl_setopt($ch, CURLOPT_NOBODY, 1); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); curl_exec($ch); $errNo = curl_errno($ch); $error = curl_error($ch); curl_close($ch); if ($errNo) { throw new RuntimeException("cURL Error $errNo connecting to remote FTP server: $error", 500); } } /** * Write the contents into the file * * @param string $fileName The full path to the file * @param string $contents The contents to write to the file * * @return boolean True on success */ public function write($fileName, $contents) { // Make sure the buffer:// wrapper is loaded class_exists('\\Akeeba\\Engine\\Util\\Buffer', true); $handle = fopen('buffer://akeeba_engine_transfer_ftp_curl', 'r+'); fwrite($handle, $contents); // Note: don't manually close the file pointer, it's closed automatically by uploadFromHandle try { $this->uploadFromHandle($fileName, $handle); } catch (RuntimeException $e) { return false; } return true; } /** * Uploads a local file to the remote storage * * @param string $localFilename The full path to the local file * @param string $remoteFilename The full path to the remote file * @param bool $useExceptions Throw an exception instead of returning "false" on connection error. * * @return boolean True on success */ public function upload($localFilename, $remoteFilename, $useExceptions = true) { $fp = @fopen($localFilename, 'r'); if ($fp === false) { throw new RuntimeException("Unreadable local file $localFilename"); } // Note: don't manually close the file pointer, it's closed automatically by uploadFromHandle try { $this->uploadFromHandle($remoteFilename, $fp); } catch (RuntimeException $e) { if ($useExceptions) { throw $e; } return false; } return true; } /** * Read the contents of a remote file into a string * * @param string $fileName The full path to the remote file * * @return string The contents of the remote file */ public function read($fileName) { try { return $this->downloadToString($fileName); } catch (RuntimeException $e) { throw new RuntimeException("Can not download remote file $fileName", 500, $e); } } /** * Download a remote file into a local file * * @param string $remoteFilename The remote file path to download from * @param string $localFilename The local file path to download to * @param bool $useExceptions Throw an exception instead of returning "false" on connection error. * * @return boolean True on success */ public function download($remoteFilename, $localFilename, $useExceptions = true) { $fp = @fopen($localFilename, 'w'); if ($fp === false) { if ($useExceptions) { throw new RuntimeException(sprintf('Download from FTP failed. Can not open local file %s for writing.', $localFilename)); } return false; } // Note: don't manually close the file pointer, it's closed automatically by downloadToHandle try { $this->downloadToHandle($remoteFilename, $fp); } catch (RuntimeException $e) { if ($useExceptions) { throw $e; } return false; } return true; } /** * Delete a file (remove it from the disk) * * @param string $fileName The full path to the file * * @return boolean True on success */ public function delete($fileName) { $commands = [ 'DELE ' . $this->getPath($fileName), ]; try { $this->executeServerCommands($commands); } catch (RuntimeException $e) { return false; } return true; } /** * Create a copy of the file. Actually, we have to read it in memory and upload it again. * * @param string $from The full path of the file to copy from * @param string $to The full path of the file that will hold the copy * * @return boolean True on success */ public function copy($from, $to) { // Make sure the buffer:// wrapper is loaded class_exists('\\Akeeba\\Engine\\Util\\Buffer', true); $handle = fopen('buffer://akeeba_engine_transfer_ftp', 'r+'); try { $this->downloadToHandle($from, $handle, false); $this->uploadFromHandle($to, $handle); } catch (RuntimeException $e) { return false; } return true; } /** * Move or rename a file * * @param string $from The full path of the file to move * @param string $to The full path of the target file * * @return boolean True on success */ public function move($from, $to) { $from = $this->getPath($from); $to = $this->getPath($to); $commands = [ 'RNFR /' . $from, 'RNTO /' . $to, ]; try { $this->executeServerCommands($commands); } catch (RuntimeException $e) { return false; } return true; } /** * Change the permissions of a file * * @param string $fileName The full path of the file whose permissions will change * @param integer $permissions The new permissions, e.g. 0644 (remember the leading zero in octal numbers!) * * @return boolean True on success */ public function chmod($fileName, $permissions) { // Make sure permissions are in an octal string representation if (!is_string($permissions)) { $permissions = decoct($permissions); } $commands = [ 'SITE CHMOD ' . $permissions . ' /' . $this->getPath($fileName), ]; try { $this->executeServerCommands($commands); } catch (RuntimeException $e) { return false; } return true; } /** * Create a directory if it doesn't exist. The operation is implicitly recursive, i.e. it will create all * intermediate directories if they do not already exist. * * @param string $dirName The full path of the directory to create * @param integer $permissions The permissions of the created directory * * @return boolean True on success */ public function mkdir($dirName, $permissions = 0755) { $targetDir = rtrim($dirName, '/'); $directories = explode('/', $targetDir); $remoteDir = ''; foreach ($directories as $dir) { if (!$dir) { continue; } $remoteDir .= '/' . $dir; // Continue if the folder already exists. Otherwise I'll get a an error even if everything is fine if ($this->isDir($remoteDir)) { continue; } $commands = [ 'MKD ' . $remoteDir, ]; try { $this->executeServerCommands($commands); } catch (RuntimeException $e) { return false; } } $this->chmod($dirName, $permissions); return true; } /** * Checks if the given directory exists * * @param string $path The full path of the remote directory to check * * @return boolean True if the directory exists */ public function isDir($path) { $ch = $this->getCurlHandle($path . '/'); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); curl_exec($ch); $errNo = curl_errno($ch); curl_close($ch); if ($errNo) { return false; } return true; } /** * Get the current working directory. NOT IMPLEMENTED. * * @return string */ public function cwd() { $commands = [ 'PWD', ]; try { $result = $this->executeServerCommands($commands, ''); } catch (RuntimeException $e) { return '/'; } return $result; } /** * Lists the subdirectories inside an FTP directory * * @param null|string $dir The directory to scan. Skip to use the current directory. * * @return array|bool A list of folders, or false if we could not get a listing * * @throws RuntimeException When the server is incompatible with our FTP folder scanner */ public function listFolders($dir = null) { if (empty($dir)) { $dir = $this->directory; } $dir = rtrim($dir, '/'); $ch = $this->getCurlHandle($dir . '/'); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); $list = curl_exec($ch); $errNo = curl_errno($ch); $error = curl_error($ch); curl_close($ch); if ($errNo) { throw new RuntimeException(sprintf("cURL Error $errNo ($error) while listing contents of directory \"%s\" – make sure the folder exists and that you have adequate permissions to it", $dir), 500); } if (empty($list)) { throw new RuntimeException("Sorry, your FTP server doesn't support our FTP directory browser."); } $folders = []; // Convert the directory listing into an array of lines without *NIX/Windows/Mac line ending characters $list = explode("\n", $list); $list = array_map('rtrim', $list); foreach ($list as $v) { $vInfo = preg_split("/[\s]+/", $v, 9); if ($vInfo[0] !== "total") { $perms = $vInfo[0]; if (substr($perms, 0, 1) == 'd') { $folders[] = $vInfo[8]; } } } asort($folders); return $folders; } /** * Is the verbose debug option set? * * @return boolean */ public function isVerbose() { return $this->verbose; } /** * Set the verbose debug option * * @param boolean $verbose * * @return void */ public function setVerbose($verbose) { $this->verbose = $verbose; } /** * Returns the absolute remote path from a path relative to the initial directory configured when creating the * transfer object. * * @param string $fileName The relative path of a file or directory * * @return string The absolute path for use by the transfer object */ public function getPath($fileName) { $fileName = ltrim(str_replace('\\', '/', $fileName), '/'); $isInitialDirectory = $fileName === $this->directory; $startsWithInitialDirectory = (strpos($fileName, rtrim($this->directory, '/') . '/') === 0) || (strpos($fileName, trim($this->directory, '/') . '/') === 0); // Relative file? Add the initial directory if (!$isInitialDirectory && !$startsWithInitialDirectory) { $fileName = '/' . trim($this->directory, '/') . (empty($fileName) ? '' : '/') . $fileName; } return '/' . ltrim($fileName, '/'); } /** * Returns a cURL resource handler for the remote FTP server * * @param string $remoteFile Optional. The remote file / folder on the FTP server you'll be manipulating with cURL. * * @return resource */ protected function getCurlHandle($remoteFile = '') { /** * Get the FTP URI * * VERY IMPORTANT! WE NEED THE DOUBLE SLASH AFTER THE HOST NAME since we are giving an absolute path. * @see https://technicalsanctuary.wordpress.com/2012/11/01/curl-curl-9-server-denied-you-to-change-to-the-given-directory/ */ $ftpUri = 'ftp://' . $this->host . '//'; $isInitialDirectory = $remoteFile === $this->directory; $startsWithInitialDirectory = (strpos($remoteFile, rtrim($this->directory, '/') . '/') === 0) || (strpos($remoteFile, trim($this->directory, '/') . '/') === 0); // Relative file? Add the initial directory if (!$isInitialDirectory && !$startsWithInitialDirectory) { $ftpUri .= '/' . trim($this->directory, '/'); } if (!empty($remoteFile) && substr($ftpUri, -2) !== '//') { $ftpUri .= '/'; } // Add a remote file if necessary. The filename must be URL encoded since we're creating a URI. if (!empty($remoteFile)) { $suffix = ''; if (substr($remoteFile, -7, 6) == ';type=') { $suffix = substr($remoteFile, -7); $remoteFile = substr($remoteFile, 0, -7); } $dirname = dirname($remoteFile); // Windows messing up dirname('/'). KILL ME. if ($dirname == '\\') { $dirname = ''; } $dirname = trim($dirname, '/'); $basename = basename($remoteFile); if ((substr($remoteFile, -1) == '/') && !empty($basename)) { $suffix = '/' . $suffix; } $ftpUri .= '/' . $dirname . (empty($dirname) ? '' : '/') . urlencode($basename) . $suffix; } // Colons in usernames must be URL escaped $username = str_replace(':', '%3A', $this->username); $ch = curl_init(); $this->applyProxySettingsToCurl($ch); curl_setopt($ch, CURLOPT_URL, $ftpUri); curl_setopt($ch, CURLOPT_USERPWD, $username . ":" . $this->password); curl_setopt($ch, CURLOPT_PORT, $this->port); curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout); // Should I enable Implict SSL? if ($this->ssl) { curl_setopt($ch, CURLOPT_FTP_SSL, CURLFTPSSL_ALL); curl_setopt($ch, CURLOPT_FTPSSLAUTH, CURLFTPAUTH_DEFAULT); // Most FTPS servers use self-signed certificates. That's the only way to connect to them :( curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); } // Should I ignore the server-supplied passive mode IP address? if ($this->passive && $this->skipPassiveIP) { curl_setopt($ch, CURLOPT_FTP_SKIP_PASV_IP, 1); } // Should I enable active mode? if (!$this->passive) { /** * cURL always uses passive mode for FTP transfers. Setting the CURLOPT_FTPPORT flag enables the FTP PORT * command which makes the connection active. Setting it to '-' lets the library use your system's default * IP address. * * @see https://curl.haxx.se/libcurl/c/CURLOPT_FTPPORT.html */ curl_setopt($ch, CURLOPT_FTPPORT, '-'); } // Should I enable verbose output? Useful for debugging. if ($this->verbose) { curl_setopt($ch, CURLOPT_VERBOSE, 1); } // Automatically create missing directories curl_setopt($ch, CURLOPT_FTP_CREATE_MISSING_DIRS, 1); return $ch; } /** * Uploads a file using file contents provided through a file handle * * @param string $remoteFilename Remote file to write contents to * @param resource $fp File or stream handler of the source data to upload * * @return void * * @throws RuntimeException */ protected function uploadFromHandle($remoteFilename, $fp) { // We need the file size. We can do that by getting the file position at EOF fseek($fp, 0, SEEK_END); $filesize = ftell($fp); rewind($fp); /** * The ;type=i suffix forces Binary file transfer mode * * @see https://curl.haxx.se/mail/archive-2008-05/0089.html */ $ch = $this->getCurlHandle($remoteFilename . ';type=i'); curl_setopt($ch, CURLOPT_UPLOAD, 1); curl_setopt($ch, CURLOPT_INFILE, $fp); curl_setopt($ch, CURLOPT_INFILESIZE, $filesize); curl_exec($ch); $error_no = curl_errno($ch); $error = curl_error($ch); curl_close($ch); fclose($fp); if ($error_no) { throw new RuntimeException($error, $error_no); } } /** * Downloads a remote file to the provided file handle * * @param string $remoteFilename Filename on the remote server * @param resource $fp File handle where the downloaded content will be written to * @param bool $close Optional. Should I close the file handle when I'm done? (Default: true) * * @return void * * @throws RuntimeException */ protected function downloadToHandle($remoteFilename, $fp, $close = true) { /** * The ;type=i suffix forces Binary file transfer mode * * @see https://curl.haxx.se/mail/archive-2008-05/0089.html */ $ch = $this->getCurlHandle($remoteFilename . ';type=i'); curl_setopt($ch, CURLOPT_FILE, $fp); curl_exec($ch); $error_no = curl_errno($ch); $error = curl_error($ch); curl_close($ch); if ($close) { fclose($fp); } if ($error_no) { throw new RuntimeException($error, $error_no); } } /** * Downloads a remote file and returns it as a string * * @param string $remoteFilename Filename on the remote server * * @return string * * @throws RuntimeException */ protected function downloadToString($remoteFilename) { /** * The ;type=i suffix forces Binary file transfer mode * * @see https://curl.haxx.se/mail/archive-2008-05/0089.html */ $ch = $this->getCurlHandle($remoteFilename . ';type=i'); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HEADER, false); $ret = curl_exec($ch); $error_no = curl_errno($ch); $error = curl_error($ch); curl_close($ch); if ($error_no) { throw new RuntimeException($error, $error_no); } return $ret; } /** * Executes arbitrary FTP commands * * @param string[] $commands An array with the FTP commands to be executed * @param string|null $remoteFile The remote file / folder to use in the cURL URI when executing the commands * * @return string The output of the executed commands * */ protected function executeServerCommands(array $commands, ?string $remoteFile = null): string { $remoteFile = $remoteFile ?? ($this->directory . '/'); $ch = $this->getCurlHandle($remoteFile); curl_setopt($ch, CURLOPT_QUOTE, $commands); curl_setopt($ch, CURLOPT_HEADER, 1); curl_setopt($ch, CURLOPT_NOBODY, 1); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); $listing = curl_exec($ch); $errNo = curl_errno($ch); $error = curl_error($ch); curl_close($ch); if ($errNo) { throw new RuntimeException($error, $errNo); } return $listing; } }