<#\'"])/'; const MIN_PHP = '8.1.0'; private ?lSFTP $_connection=null; /** * @return string[] */ public function protectedFields():array { return ['password','passphrase']; } private static function _escapePath(string $path):string { return preg_replace([self::PATH_FILTER, self::FILE_NAME_FILTER], ["/","\\\\$1"], $path); } /** * @param string $path * @param bool $escape * * @return string */ public function getRealPath(string $path, bool $escape=true):string { $path = parent::getRealPath($path); return $escape ? self::_escapePath(self::_escapePath($path)) : $path; } /** * @return string */ private function getHost():string { return $this->getOptions()->get('host'); } /** * @param string $host * * @return void */ private function setHost(string $host):void { $this->getOptions()->set('host', $host); } /** * @return int */ private function getPort():int { return $this->getOptions()->get('port', 22); } /** * @param int $port * * @return void */ private function setPort(int $port):void { $this->getOptions()->set('port', $port); } /** * @return string */ private function getUsername():string { return $this->getOptions()->get('username'); } /** * @param string $username * * @return void */ private function setUsername(string $username):void { $this->getOptions()->set('username', $username); } /** * @return int */ private function getTimeout():int { return $this->getOptions()->get('timeout', 60); } /** * @param int $timeout * * @return void */ private function setTimeout(int $timeout):void { $this->getOptions()->set('timeout', $timeout); } /** * @return string */ private function getPassword():string { return $this->getOptions()->get('password'); } /** * @param string $password * * @return void */ private function setPassword(string $password):void { $this->getOptions()->set('password', $password); } /** * @return string */ private function getPrivateKey():string { return $this->getOptions()->get('privatekey'); } /** * @param string $privatekey * * @return void */ private function setPrivateKey(string $privatekey):void { $this->getOptions()->set('privatekey', $privatekey); } /** * @return string */ private function getPassphrase():string { return $this->getOptions()->get('passphrase'); } /** * @param string $passphrase * * @return void */ private function setPassphrase(string $passphrase):void { $this->getOptions()->set('passphrase', $passphrase); } /** * @return int */ private function getRetries(): int { return $this->getOptions()->get('retries', 3); } /** * @param int $retries * * @return void */ private function setRetries(int $retries):void { $this->getOptions()->set('retries', $retries); } /** * @return void * @throws FieldsValidationException */ public function validateFields():void { if(!$this->getPath()) throw new FieldsValidationException("No path provided"); if( !str_starts_with($this->getPath(), './') ) throw new FieldsValidationException('Path must start with "./" example: ./backup'); if(strlen($this->getPath()) <= 1) throw new FieldsValidationException("Path must point to a directory and can't be only \"/\""); if(!preg_match("/^[\/a-zA-Z0-9\-_.]+$/", $this->getPath())) throw new FieldsValidationException("Invalid path provided (Allowed characters A-Z a-z 0-9 -_. and /)"); if(!$this->getHost()) throw new FieldsValidationException("No hostname provided"); if( !preg_match('/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/', $this->getHost()) && !preg_match('/^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/', $this->getHost()) && !filter_var($this->getHost(), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ) throw new FieldsValidationException("Invalid hostname provided"); if(!$this->getPort()) throw new FieldsValidationException("No port provided"); if(!$this->getUsername()) throw new FieldsValidationException("No username provided"); if(!$this->getTimeout()) throw new FieldsValidationException("No timeout provided"); if(!$this->getPassword() && !$this->getPrivateKey()) throw new FieldsValidationException("You must provide Password or Private Keys"); if($this->getPassword() && $this->getPrivateKey()) throw new FieldsValidationException("You must choose either Password or Private Keys"); if($this->getPrivateKey() && (!file_exists($this->getPrivateKey()) || !is_file($this->getPrivateKey()))) throw new FieldsValidationException("The provided Private Key not exists"); if($this->getRetries() > 10 || $this->getRetries() < 0) throw new FieldsValidationException("Invalid retries provided. Minimum 0 and Maximum 10"); } /** * @throws ConnectionException * @return void */ public function connect():void { if (version_compare(PHP_VERSION, self::MIN_PHP, '<')) { throw new ConnectionException("Minimum PHP version required is " . self::MIN_PHP . " Your current PHP version is " . PHP_VERSION); } if($this->_connection && $this->_connection->isConnected()) return; try { $hostname = filter_var($this->getHost(), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '[' . $this->getHost() . ']' : $this->getHost(); $connection = new lSFTP($hostname, $this->getPort(), $this->getTimeout()); $connection->disableStatCache(); $password = ''; if($this->getPassword()) $password = $this->getPassword(); elseif($this->getPrivateKey()) $password = PublicKeyLoader::load(file_get_contents($this->getPrivateKey()), $this->getPassphrase() ?: false); $connection->setKeepAlive(5); if(!$connection->login($this->getUsername(), $password)) throw new ConnectionException("Invalid auth details provided"); } catch(Exception $e) { throw new ConnectionException("Failed connecting. Error: " . $e->getMessage()); } $this->_connection = $connection; } /** * @return lSFTP * @throws ConnectionException */ public function getConnection():lSFTP { $this->connect(); return $this->_connection; } /** * @return void */ public function disconnect():void { if(!$this->_connection) return; $this->_connection->disconnect(); $this->_connection = null; } /** * This function triggers every time that this destination fields changes * * @return void */ public function register():void {} /** * This function triggers every time that this destination fields changes * * @return void */ public function unregister():void {} /** * @param object $data * * @return void */ public function setData(object $data):void { if(isset($data->username)) $this->setUsername($data->username); if(isset($data->password)) $this->setPassword($data->password); if(isset($data->privatekey)) $this->setPrivateKey($data->privatekey); if(isset($data->passphrase)) $this->setPassphrase($data->passphrase); if(isset($data->host)) $this->setHost($data->host); if(isset($data->port)) $this->setPort(intval($data->port)); if(isset($data->timeout)) $this->setTimeout(intval($data->timeout)); if(isset($data->retries)) $this->setRetries(intval($data->retries)); } /** * @return array */ public function getData(): array { return $this->getOptions()->getData(); } /** * @param callable $callback * @param string $message * * @return mixed * @throws IOException */ public function retries(callable $callback, string $message) { $waittime = 333000; $tries = 0; while(true) { try { return $callback(); } catch(Exception $e) { if ($tries >= $this->getRetries()) throw new IOException($e->getMessage(), $e->getCode()); $this->getLogController()->logDebug("$message. Error: {$e->getMessage()}"); if($waittime > 60000000) $waittime = 60000000; usleep($waittime); $waittime *= 2; $tries++; $this->getLogController()->logDebug("Retry $tries/{$this->getRetries()} $message"); } } } /** * @param string $directory * @param ?string $data * * @return bool * @throws IOException */ public function dirExists(string $directory, ?string $data=null): bool { $file = $this->getFileStat($directory); return $file && $file->getType() == iDestinationFile::TYPE_DIRECTORY; } /** * @param string $file * @param ?string $data * * @return bool * @throws IOException */ public function fileExists(string $file, ?string $data=null): bool { $this->getLogController()->logDebug("[fileExists] $file"); $file = $this->getFileStat($file); return $file && $file->getType() != iDestinationFile::TYPE_DIRECTORY; } /** * @param string $directory * @param bool $recursive * @param ?string $data * * @return string|null * @throws IOException */ public function createDir(string $directory, bool $recursive, ?string $data=null):?string { $this->getLogController()->logDebug("[createDir] {$this->getRealPath($directory)}"); return $this->retries(function() use ($directory, $recursive) { try { if($this->dirExists($directory)) return null; $connection = $this->getConnection(); if(!$connection->mkdir($this->getRealPath($directory), 0700, $recursive)) throw new IOException("Failed creating directory"); } catch(Exception $e) { throw new IOException($e->getMessage()); } return null; }, "Failed creating directory \"$directory\""); } /** * @param string $directory * @param ?string $data * * @throws IOException * @return void */ public function removeDir(string $directory, ?string $data=null):void { $this->getLogController()->logDebug("[removeDir] $directory"); if (!$this->dirExists($directory)) return; $list = $this->listDir($directory); $stack = []; $stack[] = [$list, $directory]; while (!empty($stack)) { list($dir_iterator, $dir_path) = array_pop($stack); while ($dir_iterator->hasNext()) { $file = $dir_iterator->getNext(); $filename = $file->getName(); $path = sprintf("%s/%s", $dir_path, $filename); if ($file->getType() == iDestinationFile::TYPE_DIRECTORY) { // Add the current dir iterator to the stack and continue with the new dir iterator. $stack[] = [$dir_iterator, $dir_path]; // Update the 'dir_path' to the new path and use 'listDir' to get a new dir iterator. $dir_path = $path; $dir_iterator = $this->listDir($dir_path); } else $this->removeFile($path); } try { $this->retries(function() use ($dir_path) { try { $connection = $this->getConnection(); if(!$connection->rmdir($this->getRealPath($dir_path))) throw new IOException("Failed deleting directory"); } catch(Exception $e) { throw new IOException($e->getMessage()); } }, "Failed deleting directory \"$directory\""); } catch(IOException $e) { throw new IOException("Failed deleting directory '{$this->getRealPath($directory)}'. Error: {$e->getMessage()}"); } } } /** * @param string $file * @param ?string $data * * @return void * @throws IOException */ public function removeFile(string $file, ?string $data=null): void { $this->getLogController()->logDebug("[removeFile] $file"); $this->retries(function() use ($file) { if(!$this->fileExists($file)) return; try { $connection = $this->getConnection(); if(!$connection->delete($this->getRealPath($file))) throw new IOException("Failed deleting file"); } catch(Exception $e) { throw new IOException($e->getMessage()); } }, "Failed deleting file \"$file\""); } /** * @param string $source * @param string $destination * @param ?string $data * * @return void * @throws IOException */ public function copyFileToLocal(string $source, string $destination, ?string $data=null):void { $this->getLogController()->logDebug("[copyFileToLocal] {$this->getRealPath($source)} -> $destination"); $this->retries(function() use ($source, $destination) { try { $connection = $this->getConnection(); if(!$connection->get($this->getRealPath($source), $destination)) throw new IOException("Failed downloading file"); } catch(Exception $e) { throw new IOException("Failed copping file to local. Error: " . $e->getMessage()); } }, "Failed downloading file \"$source\""); } /** * @param string $source * @param string $destination * @param string|null $data * * @return DestinationChunkedDownload */ public function copyFileToLocalChunked( string $source, string $destination, ?string $data = null ): DestinationChunkedDownload { $this->getLogController()->logDebug("[copyFileToLocalChunked] {$this->getRealPath($source)} -> $destination"); return new ChunkedDownload($this, $this->getRealPath($source), $destination); } /** * @param string $source * @param string $destination * @param ?string $data * * @return string|null * @throws IOException */ public function copyFileToRemote(string $source, string $destination, ?string $data=null):?string { $this->getLogController()->logDebug("[copyFileToRemote] $source -> {$this->getRealPath($destination)}"); $this->createDir(dirname($destination), true); return $this->retries(function() use ($source, $destination) { try { $connection = $this->getConnection(); if(!$connection->put($this->getRealPath($destination), $source, lSFTP::SOURCE_LOCAL_FILE)) throw new IOException("Failed uploading file"); } catch(Exception $e) { throw new IOException("Failed copping file to remote. Error: " . $e->getMessage()); } return null; }, "Failed uploading file $source"); } /** * @param string $source * @param string $destination * @param string|null $data * * @return DestinationChunkedUpload */ public function copyFileToRemoteChunked( string $source, string $destination, ?string $data = null ): DestinationChunkedUpload { $this->getLogController()->logDebug("[copyFileToRemoteChunked] $source -> {$this->getRealPath($destination)}"); return new ChunkedUpload($this, $this->getRealPath($destination)); } /** * @param string $directory * @param ?string $data * * @throws IOException * @return DestinationDirIterator */ public function listDir(string $directory, ?string $data=null): DestinationDirIterator { $this->getLogController()->logDebug("[listDir] $directory"); return new DirIterator($this, $directory); } /** * @param string $filename * * @return iDestinationFile|null * @throws IOException */ public function getFileStat(string $filename):?iDestinationFile { $this->getLogController()->logDebug("[getFileStat] $filename"); return $this->retries(function() use ($filename) { try { $connection = $this->getConnection(); if(!($details = $connection->lstat($this->getRealPath($filename)))) return null; } catch(Exception $e) { throw new IOException("Failed fetching file information for file: '$filename'. Error: " . $e->getMessage()); } $details['path'] = $filename; $details['perms'] = $details['mode']; return DestinationFile::genFile($details); }, "Failed fetching file information for file: '$filename'"); } /** * @return void */ public function routineTasks():void {} /** * @return iDestinationDiskUsage|null * @throws IOException */ public function getDiskInfo():?iDestinationDiskUsage { try { if(!$this->dirExists('/')) $this->createDir('/', true); } catch(Exception $e) { return new DestinationDiskUsage(); } return $this->retries(function() { $usage = new DestinationDiskUsage(); try { $connection = $this->getConnection(); if(!($output = $connection->exec('df -P -T ' . $this->getPath()))) return $usage; } catch(Exception $e) { return $usage; } $output = explode("\n", trim($output)); if(!isset($output[1])) return $usage; $match = preg_split("/\s+/", $output[1]); if(!sizeof($match)) return $usage; $blockSize = intval(preg_split("/\s+/", $output[0])[2]); $blockSize = $blockSize > 0 ? $blockSize : 1024; $usage->setUsageSpace($match[3]*$blockSize); $usage->setTotalSpace(($match[3] + $match[4])*$blockSize); $usage->setFreeSpace($usage->getTotalSpace() - $usage->getUsageSpace()); return $usage; }, "Failed fetching disk usage"); } /** * */ public function __destruct() { $this->disconnect(); } }