Issues (1693)

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

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