.htaccess
1 year ago
ChunkedDownload.php
1 year ago
ChunkedUpload.php
1 year ago
DirIterator.php
1 year ago
FTP.php
1 year ago
FTPClient.php
1 year ago
index.html
1 year ago
web.config
1 year ago
FTPClient.php
436 lines
| 1 | <?php |
| 2 | /* |
| 3 | * |
| 4 | * JetBackup @ package |
| 5 | * Created By Idan Ben-Ezra |
| 6 | * |
| 7 | * Copyrights @ JetApps |
| 8 | * https://www.jetapps.com |
| 9 | * |
| 10 | **/ |
| 11 | namespace JetBackup\Destination\Vendors\FTP; |
| 12 | |
| 13 | use Exception; |
| 14 | use JetBackup\Destination\DestinationFile; |
| 15 | use JetBackup\Destination\Integration\DestinationFile as iDestinationFile; |
| 16 | use JetBackup\Exception\HttpRequestException; |
| 17 | use JetBackup\Exception\IOException; |
| 18 | use JetBackup\Filesystem\File; |
| 19 | use JetBackup\Web\File\FileChunk; |
| 20 | use JetBackup\Web\File\FileDownload; |
| 21 | use JetBackup\Web\File\FileException; |
| 22 | use JetBackup\Web\File\FileStream; |
| 23 | use JetBackup\Web\JetHttp; |
| 24 | use JetBackup\Wordpress\Wordpress; |
| 25 | |
| 26 | defined( '__JETBACKUP__' ) or die( 'Restricted access' ); |
| 27 | |
| 28 | class FTPClient { |
| 29 | |
| 30 | const SERVER_TYPE_PURE_FTPD = 'Pure-FTPd'; // ---------- Welcome to Pure-FTPd [privsep] [TLS] ---------- |
| 31 | const SERVER_TYPE_PRO_FTPD = 'ProFTPD'; // ---------- Welcome to Pure-FTPd [privsep] [TLS] ---------- |
| 32 | const SERVER_TYPE_VSFTPD = 'vsFTPd'; // (vsFTPd 3.0.2) |
| 33 | const SERVER_TYPE_UNKNOWN = ''; |
| 34 | |
| 35 | const SERVER_TYPES = [self::SERVER_TYPE_PURE_FTPD,self::SERVER_TYPE_PRO_FTPD,self::SERVER_TYPE_VSFTPD]; |
| 36 | |
| 37 | const CODE_TRUNCATED = 1000; |
| 38 | |
| 39 | private string $_hostname; |
| 40 | private int $_port; |
| 41 | private string $_auth; |
| 42 | private bool $_passive; |
| 43 | private bool $_secure; |
| 44 | private int $_timeout; |
| 45 | private int $_prefer_ip; |
| 46 | private ?JetHttp $_http=null; |
| 47 | private array $_mkdir_dirs=[]; |
| 48 | private bool $_ignore_self_signed; |
| 49 | |
| 50 | /** |
| 51 | * @param string $hostname |
| 52 | * @param string $username |
| 53 | * @param string $password |
| 54 | * @param int $port |
| 55 | * @param bool $passivemode |
| 56 | * @param bool $securessl |
| 57 | * @param int $timeout |
| 58 | * @param int $prefer_ip |
| 59 | * @param bool $ignore_self_signed |
| 60 | */ |
| 61 | public function __construct(string $hostname, string $username, string $password, int $port=21, bool $passivemode=false, bool $securessl=false, int $timeout=30, int $prefer_ip = 0, bool $ignore_self_signed = true) { |
| 62 | $this->_hostname = $hostname; |
| 63 | $this->_port = $port; |
| 64 | $this->_auth = $username.':'.$password; |
| 65 | $this->_passive = $passivemode; |
| 66 | $this->_secure = $securessl; |
| 67 | $this->_timeout = $timeout; |
| 68 | $this->_prefer_ip = $prefer_ip; |
| 69 | $this->_ignore_self_signed = $ignore_self_signed; |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * @param string $perms |
| 74 | * |
| 75 | * @return int |
| 76 | */ |
| 77 | private static function _calcPermissions(string $perms):int { |
| 78 | $permissions = 0; |
| 79 | |
| 80 | if($perms[0] == 's') $permissions |= 0140000; |
| 81 | if($perms[0] == 'l') $permissions |= 0120000; |
| 82 | if($perms[0] == '-') $permissions |= 0100000; |
| 83 | if($perms[0] == 'b') $permissions |= 0060000; |
| 84 | if($perms[0] == 'd') $permissions |= 0040000; |
| 85 | if($perms[0] == 'c') $permissions |= 0020000; |
| 86 | if($perms[0] == 'p') $permissions |= 0010000; |
| 87 | |
| 88 | if($perms[1] != '-') $permissions |= 00400; |
| 89 | if($perms[2] != '-') $permissions |= 00200; |
| 90 | if($perms[3] == 'x' || $perms[3] == 's' || $perms[3] == 't') $permissions |= 00100; |
| 91 | if($perms[3] != '-' && $perms[3] != 'x') $permissions |= 04000; |
| 92 | |
| 93 | if($perms[4] != '-') $permissions |= 00040; |
| 94 | if($perms[5] != '-') $permissions |= 00020; |
| 95 | if($perms[6] == 'x' || $perms[6] == 's' || $perms[6] == 't') $permissions |= 00010; |
| 96 | if($perms[6] != '-' && $perms[6] != 'x') $permissions |= 02000; |
| 97 | |
| 98 | if($perms[7] != '-') $permissions |= 00004; |
| 99 | if($perms[8] != '-') $permissions |= 00002; |
| 100 | if($perms[9] == 'x' || $perms[9] == 's' || $perms[9] == 't') $permissions |= 00001; |
| 101 | if($perms[9] != '-' && $perms[9] != 'x') $permissions |= 01000; |
| 102 | |
| 103 | return $permissions; |
| 104 | } |
| 105 | |
| 106 | /** |
| 107 | * @param string $source |
| 108 | * @param string $destination |
| 109 | * |
| 110 | * @return void |
| 111 | * @throws FileException |
| 112 | * @throws Exception |
| 113 | */ |
| 114 | public function upload(string $source, string $destination):void { |
| 115 | |
| 116 | if(!file_exists($source) || !is_file($source)) throw new Exception("Source file not exists"); |
| 117 | |
| 118 | $this->_init(); |
| 119 | |
| 120 | try { |
| 121 | $file = new FileStream($source); |
| 122 | $this->_http |
| 123 | ->addOption(CURLOPT_FTP_FILEMETHOD, CURLFTPMETHOD_MULTICWD) |
| 124 | ->addOption(CURLOPT_FTP_CREATE_MISSING_DIRS, CURLFTP_CREATE_DIR_RETRY) |
| 125 | ->upload($this->_getURL($destination), $file); |
| 126 | } catch(HttpRequestException $e) { |
| 127 | throw new Exception($e->getMessage(), $e->getCode()); |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | public function uploadChunk(FileChunk $chunk, $destination):void { |
| 132 | |
| 133 | $this->_init(); |
| 134 | |
| 135 | try { |
| 136 | $this->_http |
| 137 | ->addOption(CURLOPT_FTP_FILEMETHOD, CURLFTPMETHOD_MULTICWD) |
| 138 | ->addOption(CURLOPT_FTP_CREATE_MISSING_DIRS, CURLFTP_CREATE_DIR_RETRY) |
| 139 | ->addOption(CURLOPT_APPEND, 1) |
| 140 | ->uploadChunk($this->_getURL($destination), $chunk); |
| 141 | } catch(HttpRequestException $e) { |
| 142 | throw new Exception($e->getMessage(), $e->getCode()); |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | /** |
| 147 | * @param string $source |
| 148 | * @param string $destination |
| 149 | * |
| 150 | * @return void |
| 151 | * @throws Exception |
| 152 | */ |
| 153 | public function download(string $source, string $destination, int $start=0, int $end=0):void { |
| 154 | |
| 155 | $file = new File($destination); |
| 156 | if($file->exists() && $file->isDir()) $destination .= '/' . basename($source); |
| 157 | |
| 158 | $file = new File(dirname($destination)); |
| 159 | if(!$file->exists() || (!$file->isDir() || $file->isLink())) throw new Exception("Destination folder not found"); |
| 160 | |
| 161 | $this->_init(); |
| 162 | |
| 163 | try { |
| 164 | $fileDownload = new FileDownload($destination); |
| 165 | if($start || $end) $this->_http->addOption(CURLOPT_RANGE, $start . '-' . $end); |
| 166 | $this->_http->download($this->_getURL($source), $fileDownload); |
| 167 | } catch(HttpRequestException $e) { |
| 168 | throw new Exception($e->getMessage(), $e->getCode()); |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | /** |
| 173 | * @param string $file |
| 174 | * |
| 175 | * @return void |
| 176 | * @throws Exception |
| 177 | */ |
| 178 | public function delete(string $file):void { |
| 179 | $this->_quote("DELE $file"); |
| 180 | } |
| 181 | |
| 182 | /** |
| 183 | * @param string $url |
| 184 | * |
| 185 | * @return string |
| 186 | */ |
| 187 | private function _getURL(string $url):string { |
| 188 | $url = preg_replace("/\/+/", "/", "/$url"); |
| 189 | return "ftp://$this->_hostname$url"; |
| 190 | } |
| 191 | |
| 192 | public function getFileSize(string $file) { |
| 193 | $this->_init(); |
| 194 | |
| 195 | try { |
| 196 | $response = $this->_http |
| 197 | ->setReturnTransfer() |
| 198 | ->setMethod(JetHttp::METHOD_HEAD) |
| 199 | ->exec($this->_getURL($file)); |
| 200 | |
| 201 | return $response->getHeaders()->getHeader('content-length') ?: 0; |
| 202 | } catch(HttpRequestException $e) { |
| 203 | if($e->getCode() == 78 || $e->getCode() == 9) return false; |
| 204 | throw $e; |
| 205 | } |
| 206 | |
| 207 | } |
| 208 | |
| 209 | /** |
| 210 | * @param string $file |
| 211 | * |
| 212 | * @return bool |
| 213 | * @throws HttpRequestException |
| 214 | */ |
| 215 | public function fileExists(string $file): bool { |
| 216 | $this->_init(); |
| 217 | |
| 218 | try { |
| 219 | $response = $this->_http |
| 220 | ->setReturnTransfer() |
| 221 | ->setMethod(JetHttp::METHOD_HEAD) |
| 222 | ->exec($this->_getURL($file)); |
| 223 | |
| 224 | // We find that in some FTP configurations, we are getting positive responses even when the file does not exist. |
| 225 | // For those false-positive responses, we have noticed that the response does not contain a 'content-length' header. |
| 226 | return $response->getHeaders()->getHeader('content-length') !== null; |
| 227 | } catch(HttpRequestException $e) { |
| 228 | if($e->getCode() == 78 || $e->getCode() == 9) return false; |
| 229 | throw $e; |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | /** |
| 234 | * @param string $directory |
| 235 | * |
| 236 | * @return void |
| 237 | * @throws Exception |
| 238 | */ |
| 239 | public function mkdir(string $directory):void { |
| 240 | $directory = trim(preg_replace("#/+#", '/', $directory), '/'); |
| 241 | $parts = explode('/', $directory); |
| 242 | |
| 243 | $path = '/'; |
| 244 | foreach($parts as $part) { |
| 245 | |
| 246 | if(in_array($path . $part . "/", $this->_mkdir_dirs)) { |
| 247 | $path .= $part . '/'; |
| 248 | continue; |
| 249 | } |
| 250 | $list = $this->listDir($path); |
| 251 | |
| 252 | foreach($list as $item) { |
| 253 | if($item->getName() == $part) { |
| 254 | $path .= $part . '/'; |
| 255 | $this->_mkdir_dirs[] = $path; |
| 256 | continue 2; |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | $this->_quote("MKD $path$part/"); |
| 261 | |
| 262 | /** |
| 263 | * Wrapping the chmod with try-catch as it's not always supported and might trigger error |
| 264 | * Not supported in: |
| 265 | * - Microsoft FTP & vsFTPd |
| 266 | * - ProFTPD Works if AllowChmod is enabled |
| 267 | */ |
| 268 | try { |
| 269 | $this->_quote("SITE CHMOD 0700 $path$part/"); |
| 270 | } catch (Exception $e) { |
| 271 | // todo - log events |
| 272 | } |
| 273 | |
| 274 | |
| 275 | $path .= $part . '/'; |
| 276 | $this->_mkdir_dirs[] = $path; |
| 277 | } |
| 278 | } |
| 279 | |
| 280 | /** |
| 281 | * @param string $directory |
| 282 | * |
| 283 | * @return void |
| 284 | * @throws Exception |
| 285 | */ |
| 286 | public function rmdir(string $directory):void { |
| 287 | $this->_quote("RMD $directory"); |
| 288 | } |
| 289 | |
| 290 | /** |
| 291 | * @param string $directory |
| 292 | * |
| 293 | * @return void |
| 294 | * @throws Exception |
| 295 | */ |
| 296 | public function chdir(string $directory):void { |
| 297 | $this->_quote("CWD $directory"); |
| 298 | } |
| 299 | |
| 300 | /** |
| 301 | * @param string $cmd |
| 302 | * |
| 303 | * @return void |
| 304 | * @throws Exception |
| 305 | */ |
| 306 | private function _quote(string $cmd):void { |
| 307 | $this->_init(); |
| 308 | |
| 309 | try { |
| 310 | $this->_http |
| 311 | ->setReturnTransfer() |
| 312 | ->addOption(CURLOPT_QUOTE, [$cmd]) |
| 313 | ->exec($this->_getURL('/')); |
| 314 | } catch(HttpRequestException $e) { |
| 315 | throw new Exception($e->getMessage(), $e->getCode()); |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | /** |
| 320 | * @param string $dir |
| 321 | * |
| 322 | * @return iDestinationFile[] |
| 323 | * @throws HttpRequestException |
| 324 | * @throws Exception |
| 325 | */ |
| 326 | public function listDir(string $dir): array { |
| 327 | |
| 328 | $dir = !$dir? '/': preg_replace("#/+#", "/", "$dir/"); |
| 329 | |
| 330 | $this->_init(); |
| 331 | |
| 332 | $response = $this->_http |
| 333 | ->setReturnTransfer() |
| 334 | ->addOption(CURLOPT_CUSTOMREQUEST, 'LIST -al') |
| 335 | ->exec($this->_getURL($dir)); |
| 336 | |
| 337 | $headers = $response->getHeaders()->getHeader('ftp'); |
| 338 | |
| 339 | $server_type = self::SERVER_TYPE_UNKNOWN; |
| 340 | |
| 341 | foreach($headers as $line) { |
| 342 | |
| 343 | if(!$server_type) { |
| 344 | foreach(self::SERVER_TYPES as $type) { |
| 345 | if(Wordpress::strContains($line, $type)) { |
| 346 | $server_type = $type; |
| 347 | break; |
| 348 | } |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | if(Wordpress::strContains($line, "226 Output truncated")) |
| 353 | throw new Exception("The LIST command output is truncated by the server, please change the FTP service configuration to a allow higher number of files to be displayed. Server Message: ". substr($line, 6), self::CODE_TRUNCATED); |
| 354 | } |
| 355 | |
| 356 | $output = []; |
| 357 | |
| 358 | switch($server_type) { |
| 359 | case self::SERVER_TYPE_UNKNOWN: |
| 360 | case self::SERVER_TYPE_PRO_FTPD: |
| 361 | case self::SERVER_TYPE_PURE_FTPD: |
| 362 | case self::SERVER_TYPE_VSFTPD: |
| 363 | |
| 364 | $line = strtok($response->getBody(), "\r\n"); |
| 365 | while ($line !== false) { |
| 366 | $parts = explode(' ', preg_replace("/\s+/", ' ', $line)); |
| 367 | $line = strtok("\r\n"); |
| 368 | |
| 369 | list($perms,,$owner,$group,$size,$month,$day,$year) = $parts; |
| 370 | |
| 371 | $filename = $parts[count($parts)-1]; |
| 372 | $link = ''; |
| 373 | if($parts[count($parts)-2] == '->') { |
| 374 | $filename = $parts[count($parts)-3]; |
| 375 | $link = $parts[count($parts)-1]; |
| 376 | } |
| 377 | |
| 378 | if($filename == '.' || $filename == '..') continue; |
| 379 | |
| 380 | $file = DestinationFile::genFile([ |
| 381 | 'perms' => self::_calcPermissions($perms), |
| 382 | 'size' => $size, |
| 383 | 'mtime' => strtotime($month . ' ' . $day . ' ' . $year), |
| 384 | 'user' => $owner, |
| 385 | 'group' => $group, |
| 386 | 'path' => $dir . $filename, |
| 387 | 'link' => $link, |
| 388 | ]); |
| 389 | |
| 390 | $output[] = $file; |
| 391 | } |
| 392 | |
| 393 | break; |
| 394 | } |
| 395 | |
| 396 | return $output; |
| 397 | } |
| 398 | |
| 399 | /** |
| 400 | * @return void |
| 401 | */ |
| 402 | private function _init():void { |
| 403 | |
| 404 | if(!$this->_http) $this->_http = JetHttp::request(); |
| 405 | $this->_http->reset(); |
| 406 | |
| 407 | $this->_http->addOption(CURLOPT_FTPPORT, $this->_passive ? null : '-') |
| 408 | ->setAuth(null, $this->_auth, CURLAUTH_BASIC) |
| 409 | ->setPort($this->_port) |
| 410 | ->setFollowLocation() |
| 411 | ->setConnectionTimeout($this->_timeout); |
| 412 | |
| 413 | if($this->_secure) { |
| 414 | if(defined('CURL_SSLVERSION_MAX_TLSv1_2')) $this->_http->addOption(CURLOPT_SSLVERSION, CURL_SSLVERSION_MAX_TLSv1_2); |
| 415 | $this->_http->addOption(CURLOPT_USE_SSL, CURLUSESSL_ALL); |
| 416 | |
| 417 | if($this->_ignore_self_signed) { |
| 418 | $this->_http->addOption(CURLOPT_USE_SSL, CURLUSESSL_ALL) |
| 419 | ->addOption(CURLOPT_SSL_VERIFYPEER, false) // Disable certificate verification |
| 420 | ->addOption(CURLOPT_SSL_VERIFYHOST, 0); // Disable hostname verification |
| 421 | } |
| 422 | |
| 423 | } |
| 424 | |
| 425 | if($this->_prefer_ip) |
| 426 | $this->_http->addOption(CURLOPT_IPRESOLVE, $this->_prefer_ip == 4? CURL_IPRESOLVE_V4: CURL_IPRESOLVE_V6); |
| 427 | } |
| 428 | |
| 429 | /** |
| 430 | * @return void |
| 431 | */ |
| 432 | public function close():void { |
| 433 | $this->_http = null; |
| 434 | } |
| 435 | } |
| 436 |