Issues (1686)

sources/ElkArte/Packages/PackageChmod.php (1 issue)

1
<?php
2
3
/**
4
 * This deals with changing of file and directory permission either with PHP or FTP
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\Packages;
18
19
use ElkArte\AbstractModel;
20
use ElkArte\Helper\FileFunctions;
21
use ElkArte\Http\FtpConnection;
22
23
/**
24
 * Class that handles teh chmod of files/directories via PHP or FTP
25
 */
26
class PackageChmod extends AbstractModel
27
{
28
	/** @var FileFunctions */
29
	protected $fileFunc;
30
31
	/**
32
	 * Basic constructor
33
	 */
34
	public function __construct()
35
	{
36
		$this->fileFunc = FileFunctions::instance();
37
		parent::__construct();
38
	}
39
40
	/**
41
	 * Create a chmod control for, you guessed it, chmod-ing files / directories.
42
	 *
43
	 * @param string[] $chmodFiles
44
	 * @param array $chmodOptions -- force_find_error, crash_on_error, destination_url
45
	 * @param bool $restore_write_status
46
	 * @return array|bool
47
	 * @package Packages
48
	 */
49
	public function createChmodControl($chmodFiles = array(), $chmodOptions = array(), $restore_write_status = false)
50
	{
51
		global $context, $package_ftp, $txt;
52
53
		// If we're restoring the status of existing files prepare the data.
54
		if ($restore_write_status && !empty($_SESSION['ftp_connection']['original_perms']))
55
		{
56
			$this->showList($restore_write_status, $chmodOptions);
57
		}
58
		// Otherwise, it's entirely irrelevant?
59
		elseif ($restore_write_status)
60
		{
61
			return true;
62
		}
63
64
		// This is where we report what we got up to.
65
		$return_data = [
66
			'files' => [
67
				'writable' => [],
68
				'notwritable' => [],
69
			],
70
		];
71
72
		// If we have some FTP information already, then let's assume it was required
73
		// and try to get ourselves reconnected.
74
		if (!empty($_SESSION['ftp_connection']['connected']))
75
		{
76
			$package_ftp = new FtpConnection($_SESSION['ftp_connection']['server'], $_SESSION['ftp_connection']['port'], $_SESSION['ftp_connection']['username'], $this->packageCrypt($_SESSION['ftp_connection']['password']));
77
78
			// Check for a valid connection
79
			if ($package_ftp->error !== false)
80
			{
81
				unset($package_ftp, $_SESSION['ftp_connection']);
82
			}
83
		}
84
85
		// Just got a submission, did we?
86
		if (isset($this->_req->post->ftp_username, $this->_req->post->ftp_password)
87
			&& (empty($package_ftp) || ($package_ftp->error !== false)))
88
		{
89
			$ftp = $this->getFTPControl();
90
		}
91
92
		// Now try to simply make the files writable, with whatever we might have.
93
		if (!empty($chmodFiles))
94
		{
95
			foreach ($chmodFiles as $k => $file)
96
			{
97
				// Sometimes this can somehow happen maybe?
98
				if (empty($file))
99
				{
100
					unset($chmodFiles[$k]);
101
				}
102
				// Already writable?
103
				elseif ($this->fileFunc->isWritable($file))
104
				{
105
					$return_data['files']['writable'][] = $file;
106
				}
107
				else
108
				{
109
					// Now try to change that.
110
					$return_data['files'][$this->pkgChmod($file, true) ? 'writable' : 'notwritable'][] = $file;
111
				}
112
			}
113
		}
114
115
		// Have we still got nasty files which ain't writable? Dear me we need more FTP good sir.
116
		if (empty($package_ftp)
117
			&& (!empty($return_data['files']['notwritable']) || !empty($chmodOptions['force_find_error'])))
118
		{
119
			$this->reportUnWritable($ftp ?? null, $chmodOptions, $return_data);
120
121
			// Sent here to die?
122
			if (!empty($chmodOptions['crash_on_error']))
123
			{
124
				$context['page_title'] = $txt['package_ftp_necessary'];
125
				$context['sub_template'] = 'ftp_required';
126
				obExit();
127
			}
128
		}
129
130
		return $return_data;
131
	}
132
133
	/**
134
	 * If file permissions were changed, provide the option to reset them
135
	 *
136
	 * @param bool $restore_write_status
137
	 * @param array $chmodOptions
138
	 * @return bool|void
139
	 */
140
	public function showList($restore_write_status, $chmodOptions)
141
	{
142
		global $context, $txt, $scripturl;
143
144
		// If we're restoring the status of existing files prepare the data.
145
		if ($restore_write_status && !empty($_SESSION['ftp_connection']['original_perms']))
146
		{
147
			$listOptions = array(
148
				'id' => 'restore_file_permissions',
149
				'title' => $txt['package_restore_permissions'],
150
				'get_items' => array(
151
					'function' => 'list_restoreFiles',
152
					'params' => array(
153
						!empty($this->_req->getPost('restore_perms')),
154
					),
155
				),
156
				'columns' => array(
157
					'path' => array(
158
						'header' => array(
159
							'value' => $txt['package_restore_permissions_filename'],
160
						),
161
						'data' => array(
162
							'db' => 'path',
163
							'class' => 'smalltext',
164
						),
165
					),
166
					'old_perms' => array(
167
						'header' => array(
168
							'value' => $txt['package_restore_permissions_orig_status'],
169
						),
170
						'data' => array(
171
							'db' => 'old_perms',
172
							'class' => 'smalltext',
173
						),
174
					),
175
					'cur_perms' => array(
176
						'header' => array(
177
							'value' => $txt['package_restore_permissions_cur_status'],
178
						),
179
						'data' => array(
180
							'function' => static function ($rowData) {
181
								global $txt;
182
								$formatTxt = $rowData['result'] === '' || $rowData['result'] === 'skipped' ? $txt['package_restore_permissions_pre_change'] : $txt['package_restore_permissions_post_change'];
183
								return sprintf($formatTxt, $rowData['cur_perms'], $rowData['new_perms'], $rowData['writable_message']);
184
							},
185
							'class' => 'smalltext',
186
						),
187
					),
188
					'check' => array(
189
						'header' => array(
190
							'value' => '<input type="checkbox" onclick="invertAll(this, this.form);" class="input_check" />',
191
							'class' => 'centertext',
192
						),
193
						'data' => array(
194
							'sprintf' => array(
195
								'format' => '<input type="checkbox" name="restore_files[]" value="%1$s" class="input_check" />',
196
								'params' => array(
197
									'path' => false,
198
								),
199
							),
200
							'class' => 'centertext',
201
						),
202
					),
203
					'result' => array(
204
						'header' => array(
205
							'value' => $txt['package_restore_permissions_result'],
206
						),
207
						'data' => array(
208
							'function' => static function ($rowData) {
209
								global $txt;
210
								return $txt['package_restore_permissions_action_' . $rowData['result']];
211
							},
212
							'class' => 'smalltext',
213
						),
214
					),
215
				),
216
				'form' => array(
217
					'href' => empty($chmodOptions['destination_url']) ? $scripturl . '?action=admin;area=packages;sa=perms;restore;' . $context['session_var'] . '=' . $context['session_id'] : $chmodOptions['destination_url'],
218
				),
219
				'additional_rows' => array(
220
					array(
221
						'position' => 'below_table_data',
222
						'value' => '<input type="submit" name="restore_perms" value="' . $txt['package_restore_permissions_restore'] . '" class="right_submit" />',
223
						'class' => 'category_header',
224
					),
225
					array(
226
						'position' => 'after_title',
227
						'value' => '<span class="smalltext">' . $txt['package_restore_permissions_desc'] . '</span>',
228
					),
229
				),
230
			);
231
232
			// Work out what columns and the like to show.
233
			if (!empty($this->_req->getPost('restore_perms')))
234
			{
235
				$listOptions['additional_rows'][1]['value'] = sprintf($txt['package_restore_permissions_action_done'], $scripturl . '?action=admin;area=packages;sa=perms;' . $context['session_var'] . '=' . $context['session_id']);
236
				unset($listOptions['columns']['check'], $listOptions['form'], $listOptions['additional_rows'][0]);
237
238
				$context['sub_template'] = 'show_list';
239
				$context['default_list'] = 'restore_file_permissions';
240
			}
241
			else
242
			{
243
				unset($listOptions['columns']['result']);
244
			}
245
246
			// Create the list for display.
247
			createList($listOptions);
248
249
			// If we just restored permissions then wherever we are, we are now done and dusted.
250
			if (!empty($this->_req->getPost('restore_perms')))
251
			{
252
				obExit();
253
			}
254
		}
255
		// Otherwise, it's entirely irrelevant?
256
		elseif ($restore_write_status)
257
		{
258
			return true;
259
		}
260
	}
261
262
	/**
263
	 * Prepares $context['package_ftp'] with whatever information we may have available.
264
	 *
265
	 * @param FtpConnection|null $ftp
266
	 * @param array $chmodOptions
267
	 * @param array $return_data
268
	 */
269
	public function reportUnWritable($ftp, $chmodOptions, $return_data)
270
	{
271
		global $context;
272
273
		$ftp_server = $this->_req->getPost('ftp_server', 'trim');
274
		$ftp_port = $this->_req->getPost('ftp_port', 'intval');
275
		$ftp_username = $this->_req->getPost('ftp_username', 'trim');
276
		$ftp_path = $this->_req->getPost('ftp_path', 'trim');
277
		$ftp_error = $_SESSION['ftp_connection']['error'] ?? null;
278
279
		if (!isset($ftp) || $ftp->error !== false)
280
		{
281
			if (!isset($ftp))
282
			{
283
				$ftp = new FtpConnection(null);
284
			}
285
			elseif ($ftp->error !== false && !isset($ftp_error))
286
			{
287
				$ftp_error = $ftp->last_message ?? '';
288
			}
289
290
			[$username, $detect_path, $found_path] = $ftp->detect_path(BOARDDIR);
291
292
			if ($found_path)
293
			{
294
				$ftp_path = $detect_path;
295
			}
296
			elseif (!isset($ftp_path))
297
			{
298
				$ftp_path = $this->_modSettings['package_path'] ?? $detect_path;
299
			}
300
		}
301
302
		// Place some hopefully useful information in the form
303
		$context['package_ftp'] = array(
304
			'server' => $ftp_server ?? ($this->_modSettings['package_server'] ?? 'localhost'),
305
			'port' => $ftp_port ?? ($this->_modSettings['package_port'] ?? '21'),
306
			'username' => $ftp_username ?? ($this->_modSettings['package_username'] ?? $username ?? ''),
307
			'path' => $ftp_path ?? ($this->_modSettings['package_path'] ?? ''),
308
			'error' => empty($ftp_error) ? null : $ftp_error,
309
			'destination' => empty($chmodOptions['destination_url']) ? '' : $chmodOptions['destination_url'],
310
		);
311
312
		// Which files failed?
313
		$context['notwritable_files'] = $context['notwritable_files'] ?? [];
314
		$context['notwritable_files'] = array_merge($context['notwritable_files'], $return_data['files']['notwritable']);
315
	}
316
317
	/**
318
	 * Using the user supplied FTP information, attempts to create a connection.  If
319
	 * successful will save the supplied data in session for use in other steps.
320
	 *
321
	 * @return FtpConnection
322
	 */
323
	public function getFTPControl()
324
	{
325
		global $package_ftp;
326
327
		// CLean up what was sent
328
		$server = $this->_req->getPost('ftp_server', 'trim', '');
329
		$port = $this->_req->getPost('ftp_port', 'intval', 21);
330
		$username = $this->_req->getPost('ftp_username', 'trim', '');
331
		$password = $this->_req->getPost('ftp_password', 'trim', '');
332
		$path = $this->_req->getPost('ftp_path', 'trim', '');
333
334
		$ftp = new FtpConnection($server, $port, $username, $password);
335
336
		// We're connected, jolly good!
337
		if ($ftp->error === false)
338
		{
339
			// Common mistake, so let's try to remedy it...
340
			if (!$ftp->chdir($path))
341
			{
342
				$ftp_error = $ftp->last_message;
343
344
				if ($ftp->chdir(preg_replace('~^/home[2]?/[^/]+~', '', $path)))
345
				{
346
					$path = preg_replace('~^/home[2]?/[^/]+~', '', $path);
347
					$ftp_error = $ftp->last_message;
348
				}
349
			}
350
351
			// A valid path was entered
352
			if (!in_array($path, array('', '/'), true) && empty($ftp_error))
353
			{
354
				$ftp_root = substr(BOARDDIR, 0, -strlen($path));
355
356
				// Avoid double//slash entries
357
				if (substr($ftp_root, -1) === '/' && (substr($path, 0, 1) === '/'))
358
				{
359
					$ftp_root = substr($ftp_root, 0, -1);
360
				}
361
			}
362
			else
363
			{
364
				$ftp_root = BOARDDIR;
365
			}
366
367
			$_SESSION['ftp_connection'] = array(
368
				'server' => $server,
369
				'port' => $port,
370
				'username' => $username,
371
				'password' => $this->packageCrypt($password),
372
				'path' => $path,
373
				'root' => rtrim($ftp_root, '\/'),
374
				'connected' => true,
375
				'error' => empty($ftp_error) ? null : $ftp_error,
376
			);
377
378
			if (!isset($this->_modSettings['package_path']) || $this->_modSettings['package_path'] !== $path)
379
			{
380
				updateSettings(['package_path' => $path]);
381
			}
382
383
			// This is now the primary connection.
384
			$package_ftp = $ftp;
385
		}
386
387
		return $ftp;
388
	}
389
390
	/**
391
	 * Try to make a file writable using PHP and/or FTP if available
392
	 *
393
	 * @param string $filename
394
	 * @param bool $track_change = false
395
	 *
396
	 * @return bool True if it worked, false if it didn't
397
	 * @package Packages
398
	 */
399
	public function pkgChmod($filename, $track_change = false)
400
	{
401
		global $package_ftp;
402
403
		// File is already writable, easy
404
		if ($this->fileFunc->isWritable($filename))
405
		{
406
			return true;
407
		}
408
409
		// If we don't have FTP, see if we can get this done
410
		if (!isset($package_ftp) || $package_ftp === false)
411
		{
412
			return $this->chmodNoFTP($filename, $track_change);
413
		}
414
415
		// If we have FTP, then we take it for a spin
416
		if (!empty($_SESSION['ftp_connection']))
417
		{
418
			return $this->chmodWithFTP($filename, $track_change);
419
		}
420
421
		// Oh dear, we failed if we get here.
422
		return false;
423
	}
424
425
	/**
426
	 * Try to make a file writable using built in PHP SplFileInfo() functions
427
	 *
428
	 * @param string $filename
429
	 * @param bool $track_change = false
430
	 * @return bool True if it worked, false if it didn't
431
	 */
432
	public function chmodNoFTP($filename, $track_change)
433
	{
434
		$chmod_file = $filename;
435
436
		for ($i = 0; $i < 2; $i++)
437
		{
438
			// Start off with a less aggressive test.
439
			if ($i === 0)
440
			{
441
				// If this file doesn't exist, then we actually want to look at whatever parent directory does.
442
				$subTraverseLimit = 2;
443
				while (!$this->fileFunc->fileExists($chmod_file) && $subTraverseLimit)
444
				{
445
					$chmod_file = dirname($chmod_file);
446
					$subTraverseLimit--;
447
				}
448
449
				// Keep track of the writable status here.
450
				$file_permissions = $this->fileFunc->filePerms($chmod_file);
451
			}
452
			elseif (!$this->fileFunc->fileExists($chmod_file))
453
			{
454
				// This looks odd, but it's an attempt to work around PHP suExec.
455
				$file_permissions = $this->fileFunc->filePerms(dirname($chmod_file));
456
				mktree(dirname($chmod_file));
457
				@touch($chmod_file);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for touch(). 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

457
				/** @scrutinizer ignore-unhandled */ @touch($chmod_file);

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...
458
				$this->fileFunc->elk_chmod($chmod_file, 0755);
459
			}
460
			else
461
			{
462
				$file_permissions = $this->fileFunc->filePerms($chmod_file);
463
			}
464
465
			// Let chmod make this file or directory writable
466
			$this->fileFunc->chmod($chmod_file);
467
468
			// The ultimate writable test.
469
			if ($this->testAccess($chmod_file))
470
			{
471
				// It worked!
472
				if ($track_change)
473
				{
474
					$_SESSION['ftp_connection']['original_perms'][$chmod_file] = $file_permissions;
475
				}
476
477
				return true;
478
			}
479
480
			if (isset($_SESSION['ftp_connection']['original_perms'][$chmod_file]))
481
			{
482
				unset($_SESSION['ftp_connection']['original_perms'][$chmod_file]);
483
			}
484
		}
485
486
		// If we're here we're a failure.
487
		return false;
488
	}
489
490
	/**
491
	 * Try to make a file writable using FTP functions
492
	 *
493
	 * @param string $filename
494
	 * @param bool $track_change = false
495
	 * @return bool True if it worked, false if it didn't
496
	 */
497
	public function chmodWithFTP($filename, $track_change)
498
	{
499
		/** @var $package_ftp FtpConnection */
500
		global $package_ftp;
501
502
		$ftp_file = setFtpName($filename);
503
504
		// If the file does not yet exist, make sure its directory is at least writable
505
		if (!$this->fileFunc->fileExists($filename) && !$this->fileFunc->isDir($filename))
506
		{
507
			$file_permissions = $this->fileFunc->filePerms(dirname($filename));
508
509
			// Make sure the directory exits and is writable
510
			mktree(dirname($filename));
511
512
			$package_ftp->create_file($ftp_file);
513
			$package_ftp->chmod($ftp_file, 0755);
514
		}
515
		else
516
		{
517
			$file_permissions = $this->fileFunc->filePerms($filename);
518
		}
519
520
		// Directories
521
		if (!$this->fileFunc->isWritable(dirname($filename)))
522
		{
523
			$package_ftp->ftp_chmod(dirname($ftp_file), [0775, 0777]);
524
		}
525
526
		if ($this->fileFunc->isDir($filename) && !$this->fileFunc->isWritable($filename))
527
		{
528
			$package_ftp->ftp_chmod($ftp_file, [0775, 0777]);
529
		}
530
531
		// File
532
		if (!$this->fileFunc->isDir($filename) && !$this->fileFunc->isWritable($filename))
533
		{
534
			$package_ftp->ftp_chmod($ftp_file, [0664, 0666]);
535
		}
536
537
		if ($this->fileFunc->isWritable($filename))
538
		{
539
			if ($track_change)
540
			{
541
				$_SESSION['ftp_connection']['original_perms'][$filename] = $file_permissions;
542
			}
543
544
			return true;
545
		}
546
547
		return false;
548
	}
549
550
	/**
551
	 * The ultimate writable test.
552
	 *
553
	 * Mind you, I'm not sure why this is needed if PHP says it is writable, but
554
	 * sometimes you have to be a lemming. Plus windows ACL is not handled well.
555
	 *
556
	 * @param $item
557
	 * @return bool
558
	 */
559
	public function testAccess($item)
560
	{
561
		$fp = $this->fileFunc->isDir($item) ? @opendir($item) : @fopen($item, 'rb');
562
		if ($this->fileFunc->isWritable($item) && $fp !== false)
563
		{
564
			if (!$this->fileFunc->isDir($item))
565
			{
566
				fclose($fp);
567
			}
568
			else
569
			{
570
				closedir($fp);
571
			}
572
573
			return true;
574
		}
575
576
		return false;
577
	}
578
579
	/**
580
	 * Used to crypt the supplied ftp password in this session
581
	 *
582
	 * - Don't be fooled by the name, this is a reversing hash function.
583
	 *  It will hash a password, and if supplied that hash will return the
584
	 *  original password.  Uses the session_id as salt
585
	 *
586
	 * @param string $pass
587
	 * @return string The encrypted password
588
	 * @package Packages
589
	 */
590
	public function packageCrypt($pass)
591
	{
592
		$n = strlen($pass);
593
594
		$salt = session_id();
595
		while (strlen($salt) < $n)
596
		{
597
			$salt .= session_id();
598
		}
599
600
		for ($i = 0; $i < $n; $i++)
601
		{
602
			$pass[$i] = chr(ord($pass[$i]) ^ (ord($salt[$i]) - 32));
603
		}
604
605
		return $pass;
606
	}
607
}