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