1 <?php
2
3 namespace Alo;
4
5 use Alo\Exception\ExtensionException as EE;
6 use Alo\Exception\FileSystemException as FE;
7 use Alo\Exception\SFTPException as SE;
8
9 if(!defined('GEN_START')) {
10 http_response_code(404);
11 die();
12 }
13
14 /**
15 * SFTP handler
16 *
17 * @author Arturas Molcanovas <a.molcanovas@gmail.com>
18 */
19 class SFTP {
20
21 /**
22 * Defines the sort direction as ascending
23 *
24 * @var int
25 */
26 const SORT_ASC = SCANDIR_SORT_ASCENDING;
27 /**
28 * Defines the sort direction as descending
29 *
30 * @var int
31 */
32 const SORT_DESC = SCANDIR_SORT_DESCENDING;
33 /**
34 * Defines a parameter as "retry count"
35 *
36 * @var int
37 */
38 const P_RETRY_COUNT = 101;
39 /**
40 * Defines a parameter as "retry wait time"
41 *
42 * @var int
43 */
44 const P_RETRY_TIME = 102;
45 /**
46 * The endpoint URL
47 *
48 * @var string
49 */
50 protected $url;
51 /**
52 * The SFTP username
53 *
54 * @var string
55 */
56 protected $user;
57 /**
58 * Path to the public authentication key
59 *
60 * @var string
61 */
62 protected $pubkey;
63 /**
64 * Path to the private authentication key
65 *
66 * @var string
67 */
68 protected $privkey;
69 /**
70 * Private authentication key password
71 *
72 * @var string
73 */
74 protected $pw;
75 /**
76 * Directory in use
77 *
78 * @var string
79 */
80 protected $dir;
81 /**
82 * The SSH2 connection
83 *
84 * @var resource
85 */
86 protected $connection;
87 /**
88 * The SFTP subsystem
89 *
90 * @var resource
91 */
92 protected $sftp;
93 /**
94 * The local directory set
95 *
96 * @var string
97 */
98 protected $local_dir;
99 /**
100 * Maximum amount of retries for an operation
101 *
102 * @var int
103 */
104 protected $retry_count_max;
105
106 /**
107 * Time in seconds between retries
108 *
109 * @var int
110 */
111 protected $retry_time;
112
113 /**
114 * Instantiates the library
115 *
116 * @param array $params Optional parameters - see class P_* constants
117 *
118 * @see self::P_RETRY_COUNT
119 * @see self::P_RETRY_TIME
120 * @throws EE When the SSH2 extension is not loaded
121 */
122 function __construct($params = []) {
123 if(!function_exists('ssh2_connect')) {
124 throw new EE('SSH2 extension not loaded', EE::E_EXT_NOT_LOADED);
125 } else {
126 $this->dir = DIR_INDEX;
127
128 $this->retry_count_max = (int)\get($params[self::P_RETRY_COUNT]);
129 $this->retry_time = (int)\get($params[self::P_RETRY_TIME]) ? $params[self::P_RETRY_TIME] : 3;
130
131 \Log::debug('SSH2 class initialised');
132 }
133 }
134
135 /**
136 * Instantiates the library
137 *
138 * @param array $params Optional parameters - see class P_* constants
139 *
140 * @see self::P_RETRY_COUNT
141 * @see self::P_RETRY_TIME
142 * @throws EE When the SSH2 extension is not loaded
143 *
144 * @return SFTP
145 */
146 static function SFTP($params = []) {
147 return new SFTP($params);
148 }
149
150 /**
151 * If no parameter is passed gets the maximum amount of retry attempts for failed operations, otherwise sets it.
152 *
153 * @author Art <a.molcanovas@gmail.com>
154 *
155 * @param int $int The amount
156 *
157 * @return boolean|int
158 */
159 function retryCount($int = -1) {
160 if($int === -1) {
161 return $this->retry_count_max;
162 } elseif(is_numeric($int) && $int >= 0) {
163 $this->retry_count_max = (int)$int;
164 \Log::debug('Retry count set to ' . $int);
165
166 return true;
167 } else {
168 return false;
169 }
170 }
171
172 /**
173 * If no parameter is passed gets the time to wait between operation retries, otherwise sets it
174 *
175 * @author Art <a.molcanovas@gmail.com>
176 *
177 * @param int $int The time in seconds
178 *
179 * @return boolean
180 */
181 function retryTime($int = -1) {
182 if($int === -1) {
183 return $this->retry_time;
184 } elseif(is_numeric($int) && $int > 0) {
185 $this->retry_time = (int)$int;
186 \Log::debug('Retry time set to ' . $int);
187
188 return true;
189 } else {
190 return false;
191 }
192 }
193
194 /**
195 * Sets the local directory
196 *
197 * @author Art <a.molcanovas@gmail.com>
198 *
199 * @param string $dir The directory path
200 *
201 * @return SFTP
202 */
203 function loc($dir) {
204 $dir = rtrim($dir, ' ' . DIRECTORY_SEPARATOR);
205 if(!$dir) {
206 $dir = DIRECTORY_SEPARATOR;
207 }
208 $this->local_dir = $dir;
209 \Log::debug('Local dir set to ' . $dir);
210
211 return $this;
212 }
213
214 /**
215 * Scans a directory for files and subdirectories
216 *
217 * @author Art <a.molcanovas@gmail.com>
218 *
219 * @param int $sorting_order The sorting order
220 *
221 * @return array ["dirs" => [], "files" => []]
222 */
223 function scandir($sorting_order = self::SORT_ASC) {
224 $this->checkSubsystem();
225
226 $dir = scandir('ssh2.sftp://' . $this->sftp . DIRECTORY_SEPARATOR . $this->dir, $sorting_order);
227 $r = [
228 'dirs' => [],
229 'files' => []
230 ];
231
232 foreach($dir as $v) {
233 //Ignore hidden
234 if($v == '.' || $v == '..' || stripos($v, '.') === 0) {
235 continue;
236 } elseif(self::isFile($v)) {
237 $r['files'][] = $v;
238 } else {
239 $r['dirs'][] = $v;
240 }
241 }
242
243 return $r;
244 }
245
246 /**
247 * Checks if the SFTP subsystem was initialised
248 *
249 * @author Art <a.molcanovas@gmail.com>
250 * @throws SE When the connection ultimately fails
251 * @return SFTP
252 */
253 function checkSubsystem() {
254 if(!$this->sftp) {
255 \Log::debug('SFTP subsystem wasn\'t initialised when a dependant'
256 . ' method was called. Initialising.');
257 $this->connect();
258 }
259
260 return $this;
261 }
262
263 /**
264 * Creates a SSH2 connection
265 *
266 * @author Art <a.molcanovas@gmail.com>
267 *
268 * @param int $attempt Current attempt number
269 *
270 * @return SFTP
271 * @throws SE When the connection ultimately fails
272 */
273 function connect($attempt = 0) {
274 if(!($this->connection = @ssh2_connect($this->url))) {
275 $msg = 'Failed to initialise SSH2 connection';
276 $attempt++;
277
278 if($attempt - 1 < $this->retry_count_max) {
279 \Log::error($msg . '. Retrying again' . ' in '
280 . $this->retry_time . ' seconds [' . $attempt
281 . '/' . $this->retry_count_max . ']');
282
283 sleep($this->retry_time);
284
285 return $this->connect($attempt);
286 } else {
287 throw new SE($msg . ' after ' . $attempt . ' attempts', SE::E_CONNECT);
288 }
289 } else {
290 \Log::debug('Initialised SSH2 connection');
291
292 return $this->auth();
293 }
294 }
295
296 /**
297 * Authenticates the SSH2 connection
298 *
299 * @author Art <a.molcanovas@gmail.com>
300 *
301 * @param int $attempt The current attempt # at authentication
302 *
303 * @return SFTP
304 * @throws SE When authentication permanently fails
305 */
306 protected function auth($attempt = 0) {
307 if(!ssh2_auth_pubkey_file($this->connection, $this->user, $this->pubkey, $this->privkey, $this->pw)) {
308 $msg = 'Failed to authenticate SSH2 connection';
309 ++$attempt;
310
311 if($attempt - 1 < $this->retry_count_max) {
312 \Log::error($msg . '. Retrying in ' . $this->retry_time
313 . ' seconds [' . $attempt . '/'
314 . $this->retry_count_max . ']');
315 sleep($this->retry_time);
316
317 return $this->auth($attempt);
318 } else {
319 throw new SE($msg . ' after ' . $attempt . ' retries', SE::E_AUTH);
320 }
321 } else {
322 \Log::debug('SSH2 connection authenticated');
323
324 return $this->ssh2_sftp();
325 }
326 }
327
328 /**
329 * Initialises the SFTP subsystem
330 *
331 * @author Art <a.molcanovas@gmail.com>
332 *
333 * @param int $attempt Current retry number
334 *
335 * @return SFTP
336 * @throws SE When initialising the SFTP system permanently fails
337 */
338 protected function ssh2_sftp($attempt = 0) {
339 if($this->sftp = @ssh2_sftp($this->connection)) {
340 \Log::debug('Initialised SFTP subsystem');
341
342 return $this;
343 } else {
344 $msg = 'Failed to initialise SFTP subsystem';
345 ++$attempt;
346
347 if($attempt - 1 < $this->retry_count_max) {
348 \Log::error($msg . '. Retrying again' . ' in '
349 . $this->retry_time . ' seconds [' . $attempt
350 . '/' . $this->retry_count_max . ']');
351 sleep($this->retry_time);
352
353 return $this->ssh2_sftp($attempt);
354 } else {
355 throw new SE($msg . ' after ' . $attempt . ' attempts', SE::E_SUBSYSTEM);
356 }
357 }
358 }
359
360 /**
361 * Checks whether a resource is a file or directory based on whether it has a file extension
362 *
363 * @author Art <a.molcanovas@gmail.com>
364 *
365 * @param string $resource The resource name to check
366 *
367 * @return boolean
368 */
369 protected static function isFile($resource) {
370 $pos = stripos($resource, '.');
371
372 return $pos !== false && $pos !== 0;
373 }
374
375 /**
376 * Downloads a file to $this->local_dir
377 *
378 * @author Art <a.molcanovas@gmail.com>
379 * @see self::$local_dir
380 *
381 * @param string $file Remote file name
382 *
383 * @throws SE When the file cannot be fetched
384 * @throws FE When the name is invalid
385 * @return SFTP
386 */
387 function downloadFile($file) {
388 \Log::debug('Downloading file ' . $file . ' to ' . $this->local_dir);
389 $fetch = $this->getFileContents($file);
390
391 $local = new File();
392 $local->dir($this->local_dir)->name($file);
393 $local->content($fetch)->write();
394
395 return $this;
396 }
397
398 /**
399 * Gets the file contents
400 *
401 * @author Art <a.molcanovas@gmail.com>
402 *
403 * @param string $file File name
404 *
405 * @return string String representation of the file
406 * @throws SE When the file cannot be fetched
407 */
408 function getFileContents($file) {
409 $this->checkSubsystem();
410 $remoteFile = $this->resolvePath($file);
411
412 $file = @file_get_contents('ssh2.sftp://' . $this->sftp . '/' . $remoteFile);
413 if($file === false) {
414 throw new SE('Failed to fetch file ' . $remoteFile, SE::E_FILE_NOT_FETCHED);
415 } else {
416 return $file;
417 }
418 }
419
420 /**
421 * Modifies the path based on whether it's relative or absolute
422 *
423 * @author Art <a.molcanovas@gmail.com>
424 *
425 * @param string $item Item name
426 *
427 * @return string The resolved path
428 */
429 protected function resolvePath($item) {
430 return (stripos($item, DIRECTORY_SEPARATOR) === 0) ? substr($item, 1) :
431 $this->dir . DIRECTORY_SEPARATOR . $item;
432 }
433
434 /**
435 * Uploads a file to the SFTP folder from the local folder
436 *
437 * @author Art <a.molcanovas@gmail.com>
438 *
439 * @param string $file File name
440 *
441 * @return boolean
442 * @throws SE When the local file cannot be read
443 */
444 function upload($file) {
445 $this->checkSubsystem();
446 $path = $this->local_dir . DIRECTORY_SEPARATOR . $file;
447
448 if(!$content = @file_get_contents($path)) {
449 throw new SE('Local file ' . $path . ' cannot be read', SE::E_LOCAL_FILE_NOT_READ);
450 } else {
451 \Log::debug('Uploading remote file ' . $file);
452
453 return $this->makeFile($file, $content);
454 }
455 }
456
457 /**
458 * Creates a file in the SFTP directory
459 *
460 * @author Art <a.molcanovas@gmail.com>
461 *
462 * @param string $file File name
463 * @param mixed $content File content
464 *
465 * @return boolean
466 * @throws SE When remote fopen fails
467 */
468 function makeFile($file, $content) {
469 $this->checkSubsystem();
470 $remoteFile = $this->resolvePath($file);
471
472 if(!$fp =
473 @fopen('ssh2.sftp://' . $this->sftp . DIRECTORY_SEPARATOR . $remoteFile, File::M_WRITE_TRUNCATE_BEGIN)
474 ) {
475 throw new SE('Failed to remotely fopen ' . $remoteFile, SE::E_FILE_CREATE_FAIL);
476 } else {
477 flock($fp, LOCK_EX);
478 fwrite($fp, $content);
479 flock($fp, LOCK_UN);
480 fclose($fp);
481 \Log::debug('Wrote remote file ' . $remoteFile);
482
483 return true;
484 }
485 }
486
487 /**
488 * Deletes an item on the SFTP server
489 *
490 * @author Art <a.molcanovas@gmail.com>
491 *
492 * @param string $item File or directory name
493 *
494 * @return boolean
495 */
496 function delete($item) {
497 $this->checkSubsystem();
498 $path = $this->resolvePath($item);
499
500 if(self::isFile($item)) {
501 $success = ssh2_sftp_unlink($this->sftp, $path);
502 } else {
503 $success = ssh2_sftp_rmdir($this->sftp, $path);
504 }
505
506 if($success) {
507 \Log::debug('Deleted ' . $item);
508
509 return true;
510 } else {
511 \Log::error('Failed to delete ' . $item);
512
513 return false;
514 }
515 }
516
517 /**
518 * Returns a string representation of SFTP credentials
519 *
520 * @author Art <a.molcanovas@gmail.com>
521 * @return string
522 */
523 function __toString() {
524 return
525 'User: ' .
526 $this->user .
527 '; PrivKey:' .
528 $this->privkey .
529 '; PubKey: ' .
530 $this->pubkey .
531 '; Password hash: ' .
532 get($this->pw) ? md5($this->pw) : 'NO HASH CONTENT SET';
533 }
534
535 /**
536 * If no parameter is passed gets the SFTP server URL, otherwise sets it.
537 *
538 * @author Art <a.molcanovas@gmail.com>
539 *
540 * @param string $url
541 *
542 * @return SFTP
543 * @throws SE When the URL is not a string
544 */
545 function url($url = '') {
546 if($url === '') {
547 return $this->url;
548 } elseif(is_string($url)) {
549 $this->url = $url;
550 \Log::debug('SFTP URL set to ' . $url);
551 } else {
552 throw new SE('Invalid URL', SE::E_URL_INVALID);
553 }
554
555 return $this;
556 }
557
558 /**
559 * If no parameter is passed gets the SFTP username, otherwise sets it.
560 *
561 * @author Art <a.molcanovas@gmail.com>
562 *
563 * @param string $user The username
564 *
565 * @throws SE When $user isn't scalar
566 * @return SFTP
567 */
568 function user($user = '') {
569 if($user === '') {
570 return $this->user;
571 } elseif(is_scalar($user)) {
572 $this->user = $user;
573 \Log::debug('SFTP user set to ' . $user);
574 } else {
575 throw new SE('Invalid username', SE::E_USER_INVALID);
576 }
577
578 return $this;
579 }
580
581 /**
582 * Sets the SFTP public key path
583 *
584 * @author Art <a.molcanovas@gmail.com>
585 *
586 * @param string $pubkey The path
587 *
588 * @throws SE When $pubkey isn't a string
589 * @return SFTP
590 */
591 function pubkey($pubkey = '') {
592 if($pubkey === '') {
593 return $this->pubkey;
594 } elseif(is_string($pubkey)) {
595 $this->pubkey = $pubkey;
596 \Log::debug('SFTP pubkey set');
597 } else {
598 throw new SE('$pubkey must be a valid path', SE::E_PATH_INVALID);
599 }
600
601 return $this;
602 }
603
604 /**
605 * If no parameter is passed gets the SFTP private key path, otherwise sets it
606 *
607 * @author Art <a.molcanovas@gmail.com>
608 *
609 * @param string $privkey The path
610 *
611 * @throws SE When the $privkey isn't a string
612 * @return SFTP
613 */
614 function privkey($privkey = '') {
615 if($privkey === '') {
616 return $this->privkey;
617 } elseif(is_string($privkey)) {
618 $this->privkey = $privkey;
619 \Log::debug('SFTP privkey set');
620 } else {
621 throw new SE('$privkey must be a valid path', SE::E_PATH_INVALID);
622 }
623
624 return $this;
625 }
626
627 /**
628 * If no parameter is passed gets the SFTP private key password, otherwise sets it
629 *
630 * @author Art <a.molcanovas@gmail.com>
631 *
632 * @param string $pw The password
633 *
634 * @throws SE When the password isn't scalar
635 * @return SFTP
636 */
637 function pw($pw = '') {
638 if($pw === '') {
639 return $this->pw;
640 } elseif(is_scalar($pw)) {
641 $this->pw = $pw;
642 \Log::debug('SFTP password set');
643 } else {
644 throw new SE('Invalid password provided', SE::E_PW_INVALID);
645 }
646
647 return $this;
648 }
649
650 /**
651 * If no argument is passed, gets the working directory, otherwise sets it.
652 *
653 * @author Art <a.molcanovas@gmail.com>
654 *
655 * @param mixed $dir
656 *
657 * @throws SE When the name is invalid
658 * @return SFTP
659 */
660 function dir($dir = -1) {
661 if($dir === -1) {
662 return $this->dir;
663 } elseif(is_scalar($dir)) {
664 $dir = trim($dir, DIRECTORY_SEPARATOR . ' ');
665 if(!$dir || $dir == DIRECTORY_SEPARATOR) {
666 $dir = '.' . DIRECTORY_SEPARATOR;
667 }
668 $this->dir = $dir;
669 \Log::debug('Directory set to ' . $dir);
670 } else {
671 throw new SE('Directory name not scalar', SE::E_NAME_INVALID);
672 }
673
674 return $this;
675 }
676
677 }
678