Completed
Pull Request — master (#32044)
by Thomas
19:39
created

SFTP::uploadFile()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
nc 1
nop 2
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * @author Andreas Fischer <[email protected]>
4
 * @author Bart Visscher <[email protected]>
5
 * @author hkjolhede <[email protected]>
6
 * @author Jörn Friedrich Dreyer <[email protected]>
7
 * @author Lennart Rosam <[email protected]>
8
 * @author Lukas Reschke <[email protected]>
9
 * @author Morris Jobke <[email protected]>
10
 * @author Robin Appelman <[email protected]>
11
 * @author Robin McCorkell <[email protected]>
12
 * @author Ross Nicoll <[email protected]>
13
 * @author SA <[email protected]>
14
 * @author Senorsen <[email protected]>
15
 * @author Thomas Müller <[email protected]>
16
 * @author Vincent Petry <[email protected]>
17
 *
18
 * @copyright Copyright (c) 2018, ownCloud GmbH
19
 * @license AGPL-3.0
20
 *
21
 * This code is free software: you can redistribute it and/or modify
22
 * it under the terms of the GNU Affero General Public License, version 3,
23
 * as published by the Free Software Foundation.
24
 *
25
 * This program is distributed in the hope that it will be useful,
26
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
27
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
28
 * GNU Affero General Public License for more details.
29
 *
30
 * You should have received a copy of the GNU Affero General Public License, version 3,
31
 * along with this program.  If not, see <http://www.gnu.org/licenses/>
32
 *
33
 */
34
namespace OCA\Files_External\Lib\Storage;
35
use function GuzzleHttp\Psr7\stream_for;
36
use Icewind\Streams\IteratorDirectory;
37
use Icewind\Streams\RetryWrapper;
38
use OCP\Files\Storage\StorageAdapter;
39
use phpseclib\Net\SFTP\Stream;
40
use Psr\Http\Message\StreamInterface;
41
42
/**
43
* Uses phpseclib's Net\SFTP class and the Net\SFTP\Stream stream wrapper to
44
* provide access to SFTP servers.
45
*/
46
class SFTP extends StorageAdapter {
47
	private $host;
48
	private $user;
49
	private $root;
50
	private $port = 22;
51
52
	private $auth;
53
54
	/**
55
	* @var SFTP
56
	*/
57
	protected $client;
58
59
	/**
60
	 * @param string $host protocol://server:port
61
	 * @return array [$server, $port]
62
	 */
63
	private function splitHost($host) {
64
		$input = $host;
65
		if (\strpos($host, '://') === false) {
66
			// add a protocol to fix parse_url behavior with ipv6
67
			$host = 'http://' . $host;
68
		}
69
70
		$parsed = \parse_url($host);
71
		if (\is_array($parsed) && isset($parsed['port'])) {
72
			return [$parsed['host'], $parsed['port']];
73
		} elseif (\is_array($parsed)) {
74
			return [$parsed['host'], 22];
75
		} else {
76
			return [$input, 22];
77
		}
78
	}
79
80
	/**
81
	 * {@inheritdoc}
82
	 */
83
	public function __construct($params) {
84
		// Register sftp://
85
		Stream::register();
86
87
		$parsedHost =  $this->splitHost($params['host']);
88
89
		$this->host = $parsedHost[0];
90
		$this->port = $parsedHost[1];
91
92
		if (!isset($params['user'])) {
93
			throw new \UnexpectedValueException('no authentication parameters specified');
94
		}
95
		$this->user = $params['user'];
96
97
		if (isset($params['public_key_auth'])) {
98
			$this->auth = $params['public_key_auth'];
99
		} elseif (isset($params['password'])) {
100
			$this->auth = $params['password'];
101
		} else {
102
			throw new \UnexpectedValueException('no authentication parameters specified');
103
		}
104
105
		$this->root
106
			= isset($params['root']) ? $this->cleanPath($params['root']) : '/';
107
108
		if ($this->root[0] != '/') {
109
			$this->root = '/' . $this->root;
110
		}
111
112
		if (\substr($this->root, -1, 1) != '/') {
113
			$this->root .= '/';
114
		}
115
	}
116
117
	/**
118
	 * Returns the connection.
119
	 *
120
	 * @return \phpseclib\Net\SFTP connected client instance
121
	 * @throws \Exception when the connection failed
122
	 */
123
	public function getConnection() {
124
		if ($this->client !== null) {
125
			return $this->client;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $this->client; (OCA\Files_External\Lib\Storage\SFTP) is incompatible with the return type documented by OCA\Files_External\Lib\Storage\SFTP::getConnection of type phpseclib\Net\SFTP.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
126
		}
127
128
		$hostKeys = $this->readHostKeys();
129
		$this->client = new \phpseclib\Net\SFTP($this->host, $this->port);
0 ignored issues
show
Documentation Bug introduced by
It seems like new \phpseclib\Net\SFTP($this->host, $this->port) of type object<phpseclib\Net\SFTP> is incompatible with the declared type object<OCA\Files_External\Lib\Storage\SFTP> of property $client.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
130
131
		// The SSH Host Key MUST be verified before login().
132
		$currentHostKey = $this->client->getServerPublicHostKey();
133
		if (\array_key_exists($this->host, $hostKeys)) {
134
			if ($hostKeys[$this->host] != $currentHostKey) {
135
				throw new \Exception('Host public key does not match known key');
136
			}
137
		} else {
138
			$hostKeys[$this->host] = $currentHostKey;
139
			$this->writeHostKeys($hostKeys);
140
		}
141
142
		if (!$this->client->login($this->user, $this->auth)) {
143
			throw new \Exception('Login failed');
144
		}
145
		return $this->client;
146
	}
147
148
	/**
149
	 * {@inheritdoc}
150
	 */
151
	public function test() {
152
		if (
153
			!isset($this->host)
154
			|| !isset($this->user)
155
		) {
156
			return false;
157
		}
158
		return $this->getConnection()->nlist() !== false;
159
	}
160
161
	/**
162
	 * {@inheritdoc}
163
	 */
164
	public function getId() {
165
		$id = 'sftp::' . $this->user . '@' . $this->host;
166
		if ($this->port !== 22) {
167
			$id .= ':' . $this->port;
168
		}
169
		// note: this will double the root slash,
170
		// we should not change it to keep compatible with
171
		// old storage ids
172
		$id .= '/' . $this->root;
173
		return $id;
174
	}
175
176
	/**
177
	 * @return string
178
	 */
179
	public function getHost() {
180
		return $this->host;
181
	}
182
183
	/**
184
	 * @return string
185
	 */
186
	public function getRoot() {
187
		return $this->root;
188
	}
189
190
	/**
191
	 * @return mixed
192
	 */
193
	public function getUser() {
194
		return $this->user;
195
	}
196
197
	/**
198
	 * @param string $path
199
	 * @return string
200
	 */
201
	private function absPath($path) {
202
		return $this->root . $this->cleanPath($path);
203
	}
204
205
	/**
206
	 * @return string|false
207
	 */
208
	private function hostKeysPath() {
209
		try {
210
			$storage_view = \OCP\Files::getStorage('files_external');
211
			if ($storage_view) {
212
				return \OC::$server->getConfig()->getSystemValue('datadirectory') .
213
					$storage_view->getAbsolutePath('') .
214
					'ssh_hostKeys';
215
			}
216
		} catch (\Exception $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
217
		}
218
		return false;
219
	}
220
221
	/**
222
	 * @param $keys
223
	 * @return bool
224
	 */
225
	protected function writeHostKeys($keys) {
226
		try {
227
			$keyPath = $this->hostKeysPath();
228
			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...
229
				$fp = \fopen($keyPath, 'w');
230
				foreach ($keys as $host => $key) {
231
					\fwrite($fp, $host . '::' . $key . "\n");
232
				}
233
				\fclose($fp);
234
				return true;
235
			}
236
		} catch (\Exception $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
237
		}
238
		return false;
239
	}
240
241
	/**
242
	 * @return array
243
	 */
244
	protected function readHostKeys() {
245
		try {
246
			$keyPath = $this->hostKeysPath();
247
			if (\file_exists($keyPath)) {
248
				$hosts = [];
249
				$keys = [];
250
				$lines = \file($keyPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
251
				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...
252
					foreach ($lines as $line) {
253
						$hostKeyArray = \explode("::", $line, 2);
254
						if (\count($hostKeyArray) == 2) {
255
							$hosts[] = $hostKeyArray[0];
256
							$keys[] = $hostKeyArray[1];
257
						}
258
					}
259
					return \array_combine($hosts, $keys);
260
				}
261
			}
262
		} catch (\Exception $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
263
		}
264
		return [];
265
	}
266
267
	/**
268
	 * {@inheritdoc}
269
	 */
270
	public function mkdir($path) {
271
		try {
272
			return $this->getConnection()->mkdir($this->absPath($path));
273
		} catch (\Exception $e) {
274
			return false;
275
		}
276
	}
277
278
	/**
279
	 * {@inheritdoc}
280
	 */
281
	public function rmdir($path) {
282
		try {
283
			$result = $this->getConnection()->delete($this->absPath($path), true);
284
			// workaround: stray stat cache entry when deleting empty folders
285
			// see https://github.com/phpseclib/phpseclib/issues/706
286
			$this->getConnection()->clearStatCache();
287
			return $result;
288
		} catch (\Exception $e) {
289
			return false;
290
		}
291
	}
292
293
	/**
294
	 * {@inheritdoc}
295
	 */
296
	public function opendir($path) {
297
		try {
298
			$list = $this->getConnection()->nlist($this->absPath($path));
299
			if ($list === false) {
300
				return false;
301
			}
302
303
			$id = \md5('sftp:' . $path);
304
			$dirStream = [];
305
			foreach ($list as $file) {
306
				if ($file != '.' && $file != '..') {
307
					$dirStream[] = $file;
308
				}
309
			}
310
			return IteratorDirectory::wrap($dirStream);
311
		} catch (\Exception $e) {
312
			return false;
313
		}
314
	}
315
316
	/**
317
	 * {@inheritdoc}
318
	 */
319
	public function filetype($path) {
320
		try {
321
			$stat = $this->getConnection()->stat($this->absPath($path));
322
			if ($stat['type'] == NET_SFTP_TYPE_REGULAR) {
323
				return 'file';
324
			}
325
326
			if ($stat['type'] == NET_SFTP_TYPE_DIRECTORY) {
327
				return 'dir';
328
			}
329
		} catch (\Exception $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
330
		}
331
		return false;
332
	}
333
334
	/**
335
	 * {@inheritdoc}
336
	 */
337
	public function file_exists($path) {
338
		try {
339
			return $this->getConnection()->stat($this->absPath($path)) !== false;
340
		} catch (\Exception $e) {
341
			return false;
342
		}
343
	}
344
345
	/**
346
	 * {@inheritdoc}
347
	 */
348
	public function unlink($path) {
349
		try {
350
			return $this->getConnection()->delete($this->absPath($path), true);
351
		} catch (\Exception $e) {
352
			return false;
353
		}
354
	}
355
356
	/**
357
	 * {@inheritdoc}
358
	 */
359
	public function fopen($path, $mode) {
360
		throw new \BadMethodCallException('fopen is no longer allowed to be called');
361
	}
362
363
	/**
364
	 * {@inheritdoc}
365
	 */
366
	public function touch($path, $mtime=null) {
367
		try {
368
			if ($mtime !== null) {
369
				return false;
370
			}
371
			if (!$this->file_exists($path)) {
372
				$this->getConnection()->put($this->absPath($path), '');
373
			} else {
374
				return false;
375
			}
376
		} catch (\Exception $e) {
377
			return false;
378
		}
379
		return true;
380
	}
381
382
	/**
383
	 * @param string $path
384
	 * @param string $target
385
	 * @throws \Exception
386
	 */
387
	public function getFile($path, $target) {
388
		$this->getConnection()->get($path, $target);
389
	}
390
391
	/**
392
	 * @param string $path
393
	 * @param string $target
394
	 * @throws \Exception
395
	 */
396
	public function uploadFile($path, $target) {
397
		$this->getConnection()->put($target, $path, NET_SFTP_LOCAL_FILE);
398
	}
399
400
	/**
401
	 * {@inheritdoc}
402
	 */
403
	public function rename($source, $target) {
404
		try {
405
			if ($this->file_exists($target)) {
406
				$this->unlink($target);
407
			}
408
			return $this->getConnection()->rename(
409
				$this->absPath($source),
410
				$this->absPath($target)
411
			);
412
		} catch (\Exception $e) {
413
			return false;
414
		}
415
	}
416
417
	/**
418
	 * {@inheritdoc}
419
	 */
420
	public function stat($path) {
421
		try {
422
			$stat = $this->getConnection()->stat($this->absPath($path));
423
424
			$mtime = $stat ? $stat['mtime'] : -1;
425
			$size = $stat ? $stat['size'] : 0;
426
427
			return ['mtime' => $mtime, 'size' => $size, 'ctime' => -1];
428
		} catch (\Exception $e) {
429
			return false;
430
		}
431
	}
432
433
	/**
434
	 * @param string $path
435
	 * @return string
436
	 */
437
	public function constructUrl($path) {
438
		// Do not pass the password here. We want to use the Net_SFTP object
439
		// supplied via stream context or fail. We only supply username and
440
		// hostname because this might show up in logs (they are not used).
441
		$url = 'sftp://' . \urlencode($this->user) . '@' . $this->host . ':' . $this->port . $this->root . $path;
442
		return $url;
443
	}
444
445
	/**
446
	 * @param string $path
447
	 * @param array $options
448
	 * @return StreamInterface
449
	 * @since 11.0.0
450
	 */
451
	public function readFile(string $path, array $options = []): StreamInterface {
452
		return stream_for($this->getConnection()->get($this->absPath($path)));
453
	}
454
455
	/**
456
	 * @param string $path
457
	 * @param StreamInterface $stream
458
	 * @return int
459
	 * @since 11.0.0
460
	 */
461
	public function writeFile(string $path, StreamInterface $stream): int {
462
		if ($this->getConnection()->put($this->absPath($path), $stream->detach())) {
463
			return $stream->getSize();
464
		}
465
		return 0;
466
	}
467
}
468