Completed
Push — master ( b34111...bbfc02 )
by Morris
18:28
created

SFTP::__construct()   C

Complexity

Conditions 7
Paths 13

Size

Total Lines 31
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 18
nc 13
nop 1
dl 0
loc 31
rs 6.7272
c 0
b 0
f 0
1
<?php
2
/**
3
 * @copyright Copyright (c) 2016, ownCloud, Inc.
4
 *
5
 * @author Andreas Fischer <[email protected]>
6
 * @author Bart Visscher <[email protected]>
7
 * @author hkjolhede <[email protected]>
8
 * @author Joas Schilling <[email protected]>
9
 * @author Jörn Friedrich Dreyer <[email protected]>
10
 * @author Lennart Rosam <[email protected]>
11
 * @author Lukas Reschke <[email protected]>
12
 * @author Morris Jobke <[email protected]>
13
 * @author Robin Appelman <[email protected]>
14
 * @author Robin McCorkell <[email protected]>
15
 * @author Ross Nicoll <[email protected]>
16
 * @author SA <[email protected]>
17
 * @author Senorsen <[email protected]>
18
 * @author Vincent Petry <[email protected]>
19
 *
20
 * @license AGPL-3.0
21
 *
22
 * This code is free software: you can redistribute it and/or modify
23
 * it under the terms of the GNU Affero General Public License, version 3,
24
 * as published by the Free Software Foundation.
25
 *
26
 * This program is distributed in the hope that it will be useful,
27
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
28
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
29
 * GNU Affero General Public License for more details.
30
 *
31
 * You should have received a copy of the GNU Affero General Public License, version 3,
32
 * along with this program.  If not, see <http://www.gnu.org/licenses/>
33
 *
34
 */
35
namespace OCA\Files_External\Lib\Storage;
36
use Icewind\Streams\IteratorDirectory;
37
use Icewind\Streams\RetryWrapper;
38
use phpseclib\Net\SFTP\Stream;
39
40
/**
41
* Uses phpseclib's Net\SFTP class and the Net\SFTP\Stream stream wrapper to
42
* provide access to SFTP servers.
43
*/
44
class SFTP extends \OC\Files\Storage\Common {
45
	private $host;
46
	private $user;
47
	private $root;
48
	private $port = 22;
49
50
	private $auth = [];
51
52
	/**
53
	 * @var \phpseclib\Net\SFTP
54
	 */
55
	protected $client;
56
57
	/**
58
	 * @param string $host protocol://server:port
59
	 * @return array [$server, $port]
60
	 */
61
	private function splitHost($host) {
62
		$input = $host;
63
		if (strpos($host, '://') === false) {
64
			// add a protocol to fix parse_url behavior with ipv6
65
			$host = 'http://' . $host;
66
		}
67
68
		$parsed = parse_url($host);
69
		if(is_array($parsed) && isset($parsed['port'])) {
70
			return [$parsed['host'], $parsed['port']];
71
		} else if (is_array($parsed)) {
72
			return [$parsed['host'], 22];
73
		} else {
74
			return [$input, 22];
75
		}
76
	}
77
78
	/**
79
	 * {@inheritdoc}
80
	 */
81
	public function __construct($params) {
82
		// Register sftp://
83
		Stream::register();
84
85
		$parsedHost =  $this->splitHost($params['host']);
86
87
		$this->host = $parsedHost[0];
88
		$this->port = $parsedHost[1];
89
90
		if (!isset($params['user'])) {
91
			throw new \UnexpectedValueException('no authentication parameters specified');
92
		}
93
		$this->user = $params['user'];
94
95
		if (isset($params['public_key_auth'])) {
96
			$this->auth[] = $params['public_key_auth'];
97
		}
98
		if (isset($params['password']) && $params['password'] !== '') {
99
			$this->auth[] = $params['password'];
100
		}
101
102
		if ($this->auth === []) {
103
			throw new \UnexpectedValueException('no authentication parameters specified');
104
		}
105
106
		$this->root
107
			= isset($params['root']) ? $this->cleanPath($params['root']) : '/';
108
109
		$this->root = '/' . ltrim($this->root, '/');
110
		$this->root = rtrim($this->root, '/') . '/';
111
	}
112
113
	/**
114
	 * Returns the connection.
115
	 *
116
	 * @return \phpseclib\Net\SFTP connected client instance
117
	 * @throws \Exception when the connection failed
118
	 */
119
	public function getConnection() {
120
		if (!is_null($this->client)) {
121
			return $this->client;
122
		}
123
124
		$hostKeys = $this->readHostKeys();
125
		$this->client = new \phpseclib\Net\SFTP($this->host, $this->port);
126
127
		// The SSH Host Key MUST be verified before login().
128
		$currentHostKey = $this->client->getServerPublicHostKey();
129
		if (array_key_exists($this->host, $hostKeys)) {
130
			if ($hostKeys[$this->host] !== $currentHostKey) {
131
				throw new \Exception('Host public key does not match known key');
132
			}
133
		} else {
134
			$hostKeys[$this->host] = $currentHostKey;
135
			$this->writeHostKeys($hostKeys);
136
		}
137
138
		$login = false;
139
		foreach ($this->auth as $auth) {
140
			$login = $this->client->login($this->user, $auth);
141
			if ($login === true) {
142
				break;
143
			}
144
		}
145
146
		if ($login === false) {
147
			throw new \Exception('Login failed');
148
		}
149
		return $this->client;
150
	}
151
152
	/**
153
	 * {@inheritdoc}
154
	 */
155
	public function test() {
156
		if (
157
			!isset($this->host)
158
			|| !isset($this->user)
159
		) {
160
			return false;
161
		}
162
		return $this->getConnection()->nlist() !== false;
163
	}
164
165
	/**
166
	 * {@inheritdoc}
167
	 */
168
	public function getId(){
169
		$id = 'sftp::' . $this->user . '@' . $this->host;
170
		if ($this->port !== 22) {
171
			$id .= ':' . $this->port;
172
		}
173
		// note: this will double the root slash,
174
		// we should not change it to keep compatible with
175
		// old storage ids
176
		$id .= '/' . $this->root;
177
		return $id;
178
	}
179
180
	/**
181
	 * @return string
182
	 */
183
	public function getHost() {
184
		return $this->host;
185
	}
186
187
	/**
188
	 * @return string
189
	 */
190
	public function getRoot() {
191
		return $this->root;
192
	}
193
194
	/**
195
	 * @return mixed
196
	 */
197
	public function getUser() {
198
		return $this->user;
199
	}
200
201
	/**
202
	 * @param string $path
203
	 * @return string
204
	 */
205
	private function absPath($path) {
206
		return $this->root . $this->cleanPath($path);
207
	}
208
209
	/**
210
	 * @return string|false
211
	 */
212
	private function hostKeysPath() {
213
		try {
214
			$storage_view = \OCP\Files::getStorage('files_external');
215 View Code Duplication
			if ($storage_view) {
216
				return \OC::$server->getConfig()->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data') .
217
					$storage_view->getAbsolutePath('') .
218
					'ssh_hostKeys';
219
			}
220
		} catch (\Exception $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
221
		}
222
		return false;
223
	}
224
225
	/**
226
	 * @param $keys
227
	 * @return bool
228
	 */
229
	protected function writeHostKeys($keys) {
230
		try {
231
			$keyPath = $this->hostKeysPath();
232
			if ($keyPath && file_exists($keyPath)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $keyPath of type string|false is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
233
				$fp = fopen($keyPath, 'w');
234
				foreach ($keys as $host => $key) {
235
					fwrite($fp, $host . '::' . $key . "\n");
236
				}
237
				fclose($fp);
238
				return true;
239
			}
240
		} catch (\Exception $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
241
		}
242
		return false;
243
	}
244
245
	/**
246
	 * @return array
247
	 */
248
	protected function readHostKeys() {
249
		try {
250
			$keyPath = $this->hostKeysPath();
251
			if (file_exists($keyPath)) {
252
				$hosts = array();
253
				$keys = array();
254
				$lines = file($keyPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
255
				if ($lines) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $lines of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
256
					foreach ($lines as $line) {
257
						$hostKeyArray = explode("::", $line, 2);
258
						if (count($hostKeyArray) === 2) {
259
							$hosts[] = $hostKeyArray[0];
260
							$keys[] = $hostKeyArray[1];
261
						}
262
					}
263
					return array_combine($hosts, $keys);
264
				}
265
			}
266
		} catch (\Exception $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
267
		}
268
		return array();
269
	}
270
271
	/**
272
	 * {@inheritdoc}
273
	 */
274
	public function mkdir($path) {
275
		try {
276
			return $this->getConnection()->mkdir($this->absPath($path));
277
		} catch (\Exception $e) {
278
			return false;
279
		}
280
	}
281
282
	/**
283
	 * {@inheritdoc}
284
	 */
285
	public function rmdir($path) {
286
		try {
287
			$result = $this->getConnection()->delete($this->absPath($path), true);
288
			// workaround: stray stat cache entry when deleting empty folders
289
			// see https://github.com/phpseclib/phpseclib/issues/706
290
			$this->getConnection()->clearStatCache();
291
			return $result;
292
		} catch (\Exception $e) {
293
			return false;
294
		}
295
	}
296
297
	/**
298
	 * {@inheritdoc}
299
	 */
300
	public function opendir($path) {
301
		try {
302
			$list = $this->getConnection()->nlist($this->absPath($path));
303
			if ($list === false) {
304
				return false;
305
			}
306
307
			$id = md5('sftp:' . $path);
0 ignored issues
show
Unused Code introduced by
$id is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
308
			$dirStream = array();
309
			foreach($list as $file) {
310
				if ($file !== '.' && $file !== '..') {
311
					$dirStream[] = $file;
312
				}
313
			}
314
			return IteratorDirectory::wrap($dirStream);
315
		} catch(\Exception $e) {
316
			return false;
317
		}
318
	}
319
320
	/**
321
	 * {@inheritdoc}
322
	 */
323
	public function filetype($path) {
324
		try {
325
			$stat = $this->getConnection()->stat($this->absPath($path));
326
			if ((int) $stat['type'] === NET_SFTP_TYPE_REGULAR) {
327
				return 'file';
328
			}
329
330
			if ((int) $stat['type'] === NET_SFTP_TYPE_DIRECTORY) {
331
				return 'dir';
332
			}
333
		} catch (\Exception $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
334
335
		}
336
		return false;
337
	}
338
339
	/**
340
	 * {@inheritdoc}
341
	 */
342
	public function file_exists($path) {
343
		try {
344
			return $this->getConnection()->stat($this->absPath($path)) !== false;
345
		} catch (\Exception $e) {
346
			return false;
347
		}
348
	}
349
350
	/**
351
	 * {@inheritdoc}
352
	 */
353
	public function unlink($path) {
354
		try {
355
			return $this->getConnection()->delete($this->absPath($path), true);
356
		} catch (\Exception $e) {
357
			return false;
358
		}
359
	}
360
361
	/**
362
	 * {@inheritdoc}
363
	 */
364
	public function fopen($path, $mode) {
365
		try {
366
			$absPath = $this->absPath($path);
0 ignored issues
show
Unused Code introduced by
$absPath is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
367
			switch($mode) {
368
				case 'r':
369
				case 'rb':
0 ignored issues
show
Coding Style introduced by
There must be a comment when fall-through is intentional in a non-empty case body
Loading history...
370
					if ( !$this->file_exists($path)) {
371
						return false;
372
					}
373
				case 'w':
374
				case 'wb':
375
				case 'a':
376
				case 'ab':
377
				case 'r+':
378
				case 'w+':
379
				case 'wb+':
380
				case 'a+':
381
				case 'x':
382
				case 'x+':
383
				case 'c':
384 View Code Duplication
				case 'c+':
385
					$context = stream_context_create(array('sftp' => array('session' => $this->getConnection())));
386
					$handle = fopen($this->constructUrl($path), $mode, false, $context);
387
					return RetryWrapper::wrap($handle);
388
			}
389
		} catch (\Exception $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
390
		}
391
		return false;
392
	}
393
394
	/**
395
	 * {@inheritdoc}
396
	 */
397
	public function touch($path, $mtime=null) {
398
		try {
399
			if (!is_null($mtime)) {
400
				return false;
401
			}
402
			if (!$this->file_exists($path)) {
403
				$this->getConnection()->put($this->absPath($path), '');
404
			} else {
405
				return false;
406
			}
407
		} catch (\Exception $e) {
408
			return false;
409
		}
410
		return true;
411
	}
412
413
	/**
414
	 * @param string $path
415
	 * @param string $target
416
	 * @throws \Exception
417
	 */
418
	public function getFile($path, $target) {
419
		$this->getConnection()->get($path, $target);
420
	}
421
422
	/**
423
	 * @param string $path
424
	 * @param string $target
425
	 * @throws \Exception
426
	 */
427
	public function uploadFile($path, $target) {
428
		$this->getConnection()->put($target, $path, NET_SFTP_LOCAL_FILE);
429
	}
430
431
	/**
432
	 * {@inheritdoc}
433
	 */
434
	public function rename($source, $target) {
435
		try {
436
			if ($this->file_exists($target)) {
437
				$this->unlink($target);
438
			}
439
			return $this->getConnection()->rename(
440
				$this->absPath($source),
441
				$this->absPath($target)
442
			);
443
		} catch (\Exception $e) {
444
			return false;
445
		}
446
	}
447
448
	/**
449
	 * {@inheritdoc}
450
	 */
451
	public function stat($path) {
452
		try {
453
			$stat = $this->getConnection()->stat($this->absPath($path));
454
455
			$mtime = $stat ? $stat['mtime'] : -1;
456
			$size = $stat ? $stat['size'] : 0;
457
458
			return array('mtime' => $mtime, 'size' => $size, 'ctime' => -1);
459
		} catch (\Exception $e) {
460
			return false;
461
		}
462
	}
463
464
	/**
465
	 * @param string $path
466
	 * @return string
467
	 */
468
	public function constructUrl($path) {
469
		// Do not pass the password here. We want to use the Net_SFTP object
470
		// supplied via stream context or fail. We only supply username and
471
		// hostname because this might show up in logs (they are not used).
472
		$url = 'sftp://' . urlencode($this->user) . '@' . $this->host . ':' . $this->port . $this->root . $path;
473
		return $url;
474
	}
475
}
476