Issues (1686)

sources/ElkArte/Http/FtpConnection.php (9 issues)

1
<?php
2
3
/**
4
 * The FtpConnection class is a Simple FTP protocol implementation.
5
 *
6
 * @package   ElkArte Forum
7
 * @copyright ElkArte Forum contributors
8
 * @license   BSD http://opensource.org/licenses/BSD-3-Clause (see accompanying LICENSE.txt file)
9
 *
10
 * This file contains code covered by:
11
 * copyright: 2011 Simple Machines (http://www.simplemachines.org)
12
 *
13
 * @version 2.0 dev
14
 *
15
 */
16
17
namespace ElkArte\Http;
18
19
use ElkArte\Helper\FileFunctions;
20
21
/**
22
 * Simple FTP protocol implementation.
23
 *
24
 * https://www.faqs.org/rfcs/rfc959.html
25
 */
26
class FtpConnection
27
{
28
	/** @var resource|string Holds the connection response */
29
	public $connection;
30
31
	/** @var string|bool Holds any errors */
32
	public $error;
33
34
	/** @var string Holds last message from the server */
35
	public $last_message;
36
37
	/** @var array Passive connection */
38
	public $pasv;
39
40
	/** @var string Holds last response message from the server */
41
	public $last_response;
42
43
	/**
44
	 * Create a new FTP connection...
45
	 *
46
	 * @param string $ftp_server The server to connect to
47
	 * @param int $ftp_port The port to connect to
48
	 * @param string $ftp_user The username
49
	 * @param string $ftp_pass The password
50
	 */
51
	public function __construct($ftp_server, $ftp_port = 21, $ftp_user = 'anonymous', $ftp_pass = '[email protected]')
52
	{
53
		// Initialize variables.
54
		$this->connection = 'no_connection';
55
		$this->error = false;
56
		$this->pasv = [];
57
58
		if ($ftp_server !== null)
0 ignored issues
show
The condition $ftp_server !== null is always true.
Loading history...
59
		{
60
			$this->connect($ftp_server, $ftp_port, $ftp_user, $ftp_pass);
61
		}
62
	}
63
64
	/**
65
	 * Connects to a server
66
	 *
67
	 * @param string $ftp_server The server to connect to
68
	 * @param int $ftp_port The port to connect to
69
	 * @param string $ftp_user The username
70
	 * @param string $ftp_pass The password
71
	 */
72
	public function connect($ftp_server, $ftp_port = 21, $ftp_user = 'anonymous', $ftp_pass = '[email protected]')
73
	{
74
		// Connect to the FTP server.
75
		set_error_handler(static function () { /* ignore errors */ });
76
		$ftp_server = $this->getServer($ftp_server);
77
		$this->connection = stream_socket_client($ftp_server . ':' . $ftp_port, $err_code, $err, 5);
78
		restore_error_handler();
79
		if (!$this->connection || $err_code !== 0)
80
		{
81
			return $this->error = empty($err) ? 'bad_server' : $err;
82
		}
83
84
		// Get the welcome message...
85
		if (!$this->check_response(220))
86
		{
87
			$this->close();
88
89
			return $this->error = 'bad_response';
90
		}
91
92
		// Send the username, it should ask for a password.
93
		fwrite($this->connection, 'USER ' . $ftp_user . "\r\n");
94
		if (!$this->check_response(331))
95
		{
96
			return $this->error = 'bad_username';
97
		}
98
99
		// Now send the password... and hope it goes okay.
100
		fwrite($this->connection, 'PASS ' . $ftp_pass . "\r\n");
101
		if (!$this->check_response(230))
102
		{
103
			return $this->error = 'bad_password';
104
		}
105
106
		return true;
107
	}
108
109
	/**
110
	 * Sanitize the supplied server string
111
	 *
112
	 * @param $ftp_server
113
	 * @return string
114
	 */
115
	public function getServer($ftp_server)
116
	{
117
		$location = parse_url($ftp_server);
118
		$location['host'] = $location['host'] ?? $ftp_server;
119
		$location['scheme'] = $location['scheme'] ?? '';
120
121
		$ftp_scheme = '';
122
		if ($location['scheme'] === 'ftps' || $location['scheme'] === 'https')
123
		{
124
			$ftp_scheme = 'ssl://';
125
		}
126
127
		return $ftp_scheme . strtr($location['host'], array('/' => '', ':' => '', '@' => ''));
128
	}
129
130
	/**
131
	 * Reads the response to the command from the server
132
	 *
133
	 * @param string[]|string $desired string or array of acceptable return values
134
	 *
135
	 * @return bool
136
	 */
137
	public function check_response($desired)
138
	{
139
		$return_code = false;
140
		$time = time();
141
		while (!$return_code && time() - $time < 4)
142
		{
143
			$this->last_message = fgets($this->connection, 1024);
0 ignored issues
show
It seems like $this->connection can also be of type string; however, parameter $stream of fgets() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

143
			$this->last_message = fgets(/** @scrutinizer ignore-type */ $this->connection, 1024);
Loading history...
144
145
			// A reply will start with a 3-digit code, followed by space " ", followed by one line of text
146
			if (preg_match('~^(\d\d\d)\s(.+)$~m', $this->last_message, $matches) === 1)
147
			{
148
				$return_code = (int) $matches[1];
149
				$this->last_response = $return_code . ' :: ' . $matches[2];
150
			}
151
		}
152
153
		// Was the desired response returned?
154
		return is_array($desired) ? in_array($return_code, $desired) : $return_code === $desired;
155
	}
156
157
	/**
158
	 * Changes to a directory (chdir) via the ftp connection
159
	 *
160
	 * @param string $ftp_path The path to the directory
161
	 * @return bool
162
	 */
163
	public function chdir($ftp_path)
164
	{
165
		if (!$this->hasConnection())
166
		{
167
			return false;
168
		}
169
170
		// No slash on the end, please...
171
		if ($ftp_path !== '/' && substr($ftp_path, -1) === '/')
172
		{
173
			$ftp_path = substr($ftp_path, 0, -1);
174
		}
175
176
		fwrite($this->connection, 'CWD ' . $ftp_path . "\r\n");
177
		if (!$this->check_response(250))
178
		{
179
			$this->error = 'bad_path';
180
181
			return false;
182
		}
183
184
		return true;
185
	}
186
187
	/**
188
	 * Changes a files attributes (chmod)
189
	 *
190
	 * @param string $ftp_file The file to CHMOD
191
	 * @param int $chmod The value for the CHMOD operation
192
	 * @return bool If the chmod was successful or not
193
	 */
194
	public function chmod($ftp_file, $chmod)
195
	{
196
		if (!$this->hasConnection())
197
		{
198
			return false;
199
		}
200
201
		if (trim($ftp_file) === '')
202
		{
203
			$ftp_file = '.';
204
		}
205
206
		// Convert the chmod value from octal (0777) to text ("777").
207
		fwrite($this->connection, 'SITE CHMOD ' . decoct($chmod) . ' ' . $ftp_file . "\r\n");
208
		if (!$this->check_response(200))
209
		{
210
			$this->error = $this->last_response;
211
212
			return false;
213
		}
214
215
		return true;
216
	}
217
218
	/**
219
	 * Uses a supplied list of modes to make a file or directory writable
220
	 * assumes supplied name is relative from boarddir, which it should be
221
	 *
222
	 * @param string $ftp_file
223
	 * @param array|int $chmod
224
	 * @return bool
225
	 */
226
	public function ftp_chmod($ftp_file, $chmod)
227
	{
228
		$chmod = is_array($chmod) ? $chmod : (array) $chmod;
229
230
		foreach ($chmod as $permission)
231
		{
232
			if (!$this->chmod($ftp_file, $permission))
233
			{
234
				continue;
235
			}
236
237
			if (FileFunctions::instance()->isWritable($_SESSION['ftp_connection']['root'] . '/' . ltrim($ftp_file, '\/')))
238
			{
239
				return true;
240
			}
241
		}
242
243
		return false;
244
	}
245
246
	/**
247
	 * Deletes a file
248
	 *
249
	 * @param string $ftp_file The file to delete
250
	 * @return bool If delete was successful or not
251
	 */
252
	public function unlink($ftp_file)
253
	{
254
		// We are actually connected, right?
255
		if (!$this->hasConnection())
256
		{
257
			return false;
258
		}
259
260
		// Delete file X.
261
		fwrite($this->connection, 'DELE ' . $ftp_file . "\r\n");
262
		if (!$this->check_response(250))
263
		{
264
			fwrite($this->connection, 'RMD ' . $ftp_file . "\r\n");
265
266
			// Still no love?
267
			if (!$this->check_response(250))
268
			{
269
				$this->error = 'bad_file';
270
271
				return false;
272
			}
273
		}
274
275
		return true;
276
	}
277
278
	/**
279
	 * Creates a new file on the server
280
	 *
281
	 * @param string $ftp_file The file to create
282
	 * @return bool If we were able to create the file
283
	 */
284
	public function create_file($ftp_file)
285
	{
286
		// First, we have to be connected... very important.
287
		if (!$this->hasConnection())
288
		{
289
			return false;
290
		}
291
292
		// I'd like one passive mode, please!
293
		if (!$this->passive())
294
		{
295
			return false;
296
		}
297
298
		// Seems logical enough, so far...
299
		fwrite($this->connection, 'STOR ' . $ftp_file . "\r\n");
300
301
		// Okay, now we connect to the data port.  If it doesn't work out, it's probably "file already exists", etc.
302
		set_error_handler(static function () { /* ignore errors */ });
303
		$fp = stream_socket_client($this->pasv['ip'] . ':' . $this->pasv['port'], $err_code, $err, 5);
304
		restore_error_handler();
305
		if ($fp === false || $err_code !== 0 || !$this->check_response(150))
306
		{
307
			$this->error = 'bad_file';
308
			fclose($fp);
309
310
			return false;
311
		}
312
313
		// This may look strange, but we're just closing it to indicate a zero-byte upload.
314
		fclose($fp);
315
		if (!$this->check_response(226))
316
		{
317
			$this->error = 'bad_response';
318
319
			return false;
320
		}
321
322
		return true;
323
	}
324
325
	/**
326
	 * Used to create a passive connection
327
	 *
328
	 * @return bool If the connection was made or not
329
	 */
330
	public function passive()
331
	{
332
		// We can't create a passive data connection without a primary one first being there.
333
		if (!$this->hasConnection())
334
		{
335
			return false;
336
		}
337
338
		// Request a IPV4 passive connection - this means, we'll talk to you, you don't talk to us.
339
		fwrite($this->connection, 'PASV' . "\r\n");
340
341
		// If it's not 227, we weren't given an IP and port, which means it failed.
342
		// If it's 425, that may indicate a response to use EPSV (ipv6) which we don't support
343
		if (!$this->check_response(227))
344
		{
345
			$this->error = $this->last_response;
346
347
			return false;
348
		}
349
350
		// Snatch the IP and port information, or die horribly trying...
351
		if (preg_match('~\((\d+),\s*(\d+),\s*(\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+))\)~', $this->last_response, $match) !== 1)
352
		{
353
			$this->error = 'bad_response';
354
355
			return false;
356
		}
357
358
		// This is pretty simple - store it for later use ;).
359
		$this->pasv = [
360
			'ip' => $match[1] . '.' . $match[2] . '.' . $match[3] . '.' . $match[4],
361
			'port' => $match[5] * 256 + $match[6]
362
		];
363
364
		return true;
365
	}
366
367
	/**
368
	 * Creates a new directory on the server
369
	 *
370
	 * @param string $ftp_dir The name of the directory to create
371
	 * @return bool If the operation was successful
372
	 */
373
	public function create_dir($ftp_dir)
374
	{
375
		// We must be connected to the server to do something.
376
		if (!$this->hasConnection())
377
		{
378
			return false;
379
		}
380
381
		// Make this new beautiful directory!
382
		fwrite($this->connection, 'MKD ' . $ftp_dir . "\r\n");
383
		if (!$this->check_response(257))
384
		{
385
			$this->error = 'bad_file';
386
387
			return false;
388
		}
389
390
		return true;
391
	}
392
393
	/**
394
	 * Detects the current path
395
	 *
396
	 * @param string $filesystem_path The full path from the filesystem
397
	 * @param string|null $lookup_file The name of a file in the specified path
398
	 * @return array string $username, string $path, bool found_path
399
	 */
400
	public function detect_path($filesystem_path, $lookup_file = null)
401
	{
402
		$username = '';
403
404
		if (isset($_SERVER['DOCUMENT_ROOT']))
405
		{
406
			if (preg_match('~^/home[2]?/([^/]+)/public_html~', $_SERVER['DOCUMENT_ROOT'], $match))
407
			{
408
				$username = $match[1];
409
410
				$path = strtr($_SERVER['DOCUMENT_ROOT'], array('/home/' . $match[1] . '/' => '', '/home2/' . $match[1] . '/' => ''));
411
412
				if (substr($path, -1) === '/')
413
				{
414
					$path = substr($path, 0, -1);
415
				}
416
417
				if (strlen(dirname($_SERVER['PHP_SELF'])) > 1)
418
				{
419
					$path .= dirname($_SERVER['PHP_SELF']);
420
				}
421
			}
422
			elseif (strpos($filesystem_path, '/var/www/') === 0)
423
			{
424
				$path = substr($filesystem_path, 8);
425
			}
426
			else
427
			{
428
				$path = strtr(strtr($filesystem_path, array('\\' => '/')), array($_SERVER['DOCUMENT_ROOT'] => ''));
429
			}
430
		}
431
		else
432
		{
433
			$path = '';
434
		}
435
436
		if ($this->hasConnection() && $this->list_dir($path) === '')
0 ignored issues
show
The condition $this->list_dir($path) === '' is always false.
Loading history...
437
		{
438
			$data = $this->list_dir('', true);
439
440
			if ($lookup_file === null)
441
			{
442
				$lookup_file = $_SERVER['PHP_SELF'];
443
			}
444
445
			$found_path = dirname($this->locate('*' . basename(dirname($lookup_file)) . '/' . basename($lookup_file), $data));
446
			if ($found_path === '.')
447
			{
448
				$found_path = dirname($this->locate(basename($lookup_file)));
449
			}
450
451
			$path = $found_path;
452
		}
453
		elseif ($this->hasConnection())
454
		{
455
			$found_path = true;
456
		}
457
458
		return [$username, $path, isset($found_path)];
459
	}
460
461
	/**
462
	 * Generates a directory listing for the current directory
463
	 *
464
	 * @param string $ftp_path The path to the directory
465
	 * @param string|bool $search Whether or not to get a recursive directory listing
466
	 * @return false|string The results of the command or false if unsuccessful
467
	 */
468
	public function list_dir($ftp_path = '', $search = false)
469
	{
470
		// Are we even connected...?
471
		if (!$this->hasConnection())
472
		{
473
			return false;
474
		}
475
476
		// Passive... non-aggressive...
477
		if (!$this->passive())
478
		{
479
			return false;
480
		}
481
482
		// Get the listing!
483
		fwrite($this->connection, 'LIST -1' . ($search ? 'R' : '') . ($ftp_path === '' ? '' : ' ' . $ftp_path) . "\r\n");
484
485
		// Connect, assuming we've got a connection.
486
		$fp = @fsockopen($this->pasv['ip'], $this->pasv['port'], $err, $err, 5);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $err seems to be never defined.
Loading history...
487
		if (!$fp || !$this->check_response(array(150, 125)))
0 ignored issues
show
$fp is of type false|resource, thus it always evaluated to false.
Loading history...
488
		{
489
			$this->error = 'bad_response';
490
			@fclose($fp);
0 ignored issues
show
It seems like $fp can also be of type false; however, parameter $stream of fclose() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

490
			@fclose(/** @scrutinizer ignore-type */ $fp);
Loading history...
Security Best Practice introduced by
It seems like you do not handle an error condition for fclose(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

490
			/** @scrutinizer ignore-unhandled */ @fclose($fp);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
491
492
			return false;
493
		}
494
495
		// Read in the file listing.
496
		$data = '';
497
		while (!feof($fp))
498
		{
499
			$data .= fread($fp, 4096);
500
		}
501
502
		fclose($fp);
503
504
		// Everything go okay?
505
		if (!$this->check_response(226))
506
		{
507
			$this->error = 'bad_response';
508
509
			return false;
510
		}
511
512
		return $data;
513
	}
514
515
	/**
516
	 * Determines the current directory we are in
517
	 *
518
	 * @param string $file The name of a file
519
	 * @param string|null $listing A directory listing or null to generate one
520
	 * @return string|false The name of the file or false if it wasn't found
521
	 */
522
	public function locate($file, $listing = null)
523
	{
524
		if ($listing === null)
525
		{
526
			$listing = $this->list_dir('', true);
527
		}
528
529
		$listing = explode("\n", $listing);
0 ignored issues
show
It seems like $listing can also be of type false; however, parameter $string of explode() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

529
		$listing = explode("\n", /** @scrutinizer ignore-type */ $listing);
Loading history...
530
531
		$current_dir = '';
532
		fwrite($this->connection, 'PWD' . "\r\n");
0 ignored issues
show
It seems like $this->connection can also be of type string; however, parameter $stream of fwrite() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

532
		fwrite(/** @scrutinizer ignore-type */ $this->connection, 'PWD' . "\r\n");
Loading history...
533
		if ($this->check_response(257))
534
		{
535
			$current_dir = strtr($this->last_response, ['""' => '"']);
536
		}
537
538
		for ($i = 0, $n = count($listing); $i < $n; $i++)
539
		{
540
			if (trim($listing[$i]) === '' && isset($listing[$i + 1]))
541
			{
542
				$current_dir = substr(trim($listing[++$i]), 0, -1);
543
				$i++;
544
			}
545
546
			// Okay, this file's name is:
547
			$listing[$i] = $current_dir . '/' . trim(strlen($listing[$i]) > 30 ? strrchr($listing[$i], ' ') : $listing[$i]);
548
549
			if ($file[0] === '*' && substr($listing[$i], -(strlen($file) - 1)) === substr($file, 1))
550
			{
551
				return $listing[$i];
552
			}
553
554
			if (substr($file, -1) === '*' && substr($listing[$i], 0, strlen($file) - 1) === substr($file, 0, -1))
555
			{
556
				return $listing[$i];
557
			}
558
559
			if (basename($listing[$i]) === $file || $listing[$i] === $file)
560
			{
561
				return $listing[$i];
562
			}
563
		}
564
565
		return false;
566
	}
567
568
	/**
569
	 * Close the ftp connection
570
	 *
571
	 * @return bool
572
	 */
573
	public function close()
574
	{
575
		// Goodbye!
576
		if ($this->hasConnection())
577
		{
578
			fwrite($this->connection, 'QUIT' . "\r\n");
579
			fclose($this->connection);
580
		}
581
582
		return true;
583
	}
584
585
	/**
586
	 * If we are connected
587
	 *
588
	 * @return bool
589
	 */
590
	public function hasConnection()
591
	{
592
		return is_resource($this->connection);
593
	}
594
}
595