PackageChmod::__construct()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 2
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 0
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 Beta 1
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 = [], $chmodOptions = [], $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|null
139
	 */
140
	public function showList($restore_write_status, $chmodOptions): ?bool
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 = [
148
				'id' => 'restore_file_permissions',
149
				'title' => $txt['package_restore_permissions'],
150
				'get_items' => [
151
					'function' => 'list_restoreFiles',
152
					'params' => [
153
						!empty($this->_req->getPost('restore_perms')),
154
					],
155
				],
156
				'columns' => [
157
					'path' => [
158
						'header' => [
159
							'value' => $txt['package_restore_permissions_filename'],
160
						],
161
						'data' => [
162
							'db' => 'path',
163
							'class' => 'smalltext',
164
						],
165
					],
166
					'old_perms' => [
167
						'header' => [
168
							'value' => $txt['package_restore_permissions_orig_status'],
169
						],
170
						'data' => [
171
							'db' => 'old_perms',
172
							'class' => 'smalltext',
173
						],
174
					],
175
					'cur_perms' => [
176
						'header' => [
177
							'value' => $txt['package_restore_permissions_cur_status'],
178
						],
179
						'data' => [
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' => [
189
						'header' => [
190
							'value' => '<input type="checkbox" onclick="invertAll(this, this.form);" class="input_check" />',
191
							'class' => 'centertext',
192
						],
193
						'data' => [
194
							'sprintf' => [
195
								'format' => '<input type="checkbox" name="restore_files[]" value="%1$s" class="input_check" />',
196
								'params' => [
197
									'path' => false,
198
								],
199
							],
200
							'class' => 'centertext',
201
						],
202
					],
203
					'result' => [
204
						'header' => [
205
							'value' => $txt['package_restore_permissions_result'],
206
						],
207
						'data' => [
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' => [
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' => [
220
					[
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
					[
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
		return null;
262
	}
263
264
	/**
265
	 * Prepares $context['package_ftp'] with whatever information we may have available.
266
	 *
267
	 * @param FtpConnection|null $ftp
268
	 * @param array $chmodOptions
269
	 * @param array $return_data
270
	 */
271
	public function reportUnWritable($ftp, $chmodOptions, $return_data): void
272
	{
273
		global $context;
274
275
		$ftp_server = $this->_req->getPost('ftp_server', 'trim');
276
		$ftp_port = $this->_req->getPost('ftp_port', 'intval');
277
		$ftp_username = $this->_req->getPost('ftp_username', 'trim');
278
		$ftp_path = $this->_req->getPost('ftp_path', 'trim');
279
		$ftp_error = $_SESSION['ftp_connection']['error'] ?? null;
280
281
		if (!isset($ftp) || $ftp->error !== false)
282
		{
283
			if (!isset($ftp))
284
			{
285
				$ftp = new FtpConnection(null);
286
			}
287
			elseif ($ftp->error !== false && !isset($ftp_error))
288
			{
289
				$ftp_error = $ftp->last_message ?? '';
290
			}
291
292
			[$username, $detect_path, $found_path] = $ftp->detect_path(BOARDDIR);
293
294
			if ($found_path)
295
			{
296
				$ftp_path = $detect_path;
297
			}
298
			elseif (!isset($ftp_path))
299
			{
300
				$ftp_path = $this->_modSettings['package_path'] ?? $detect_path;
301
			}
302
		}
303
304
		// Place some hopefully useful information in the form
305
		$context['package_ftp'] = [
306
			'server' => $ftp_server ?? ($this->_modSettings['package_server'] ?? 'localhost'),
307
			'port' => $ftp_port ?? ($this->_modSettings['package_port'] ?? '21'),
308
			'username' => $ftp_username ?? ($this->_modSettings['package_username'] ?? $username ?? ''),
309
			'path' => $ftp_path ?? ($this->_modSettings['package_path'] ?? ''),
310
			'error' => empty($ftp_error) ? null : $ftp_error,
311
			'destination' => empty($chmodOptions['destination_url']) ? '' : $chmodOptions['destination_url'],
312
		];
313
314
		// Which files failed?
315
		$context['notwritable_files'] = $context['notwritable_files'] ?? [];
316
		$context['notwritable_files'] = array_merge($context['notwritable_files'], $return_data['files']['notwritable']);
317
	}
318
319
	/**
320
	 * Using the user supplied FTP information, attempts to create a connection.  If
321
	 * successful will save the supplied data in session for use in other steps.
322
	 *
323
	 * @return FtpConnection
324
	 */
325
	public function getFTPControl(): FtpConnection
326
	{
327
		global $package_ftp;
328
329
		// CLean up what was sent
330
		$server = $this->_req->getPost('ftp_server', 'trim', '');
331
		$port = $this->_req->getPost('ftp_port', 'intval', 21);
332
		$username = $this->_req->getPost('ftp_username', 'trim', '');
333
		$password = $this->_req->getPost('ftp_password', 'trim', '');
334
		$path = $this->_req->getPost('ftp_path', 'trim', '');
335
336
		$ftp = new FtpConnection($server, $port, $username, $password);
337
338
		// We're connected, jolly good!
339
		if ($ftp->error === false)
340
		{
341
			// Common mistake, so let's try to remedy it...
342
			if (!$ftp->chdir($path))
343
			{
344
				$ftp_error = $ftp->last_message;
345
346
				if ($ftp->chdir(preg_replace('~^/home[2]?/[^/]+~', '', $path)))
347
				{
348
					$path = preg_replace('~^/home[2]?/[^/]+~', '', $path);
349
					$ftp_error = $ftp->last_message;
350
				}
351
			}
352
353
			// A valid path was entered
354
			if (!in_array($path, ['', '/'], true) && empty($ftp_error))
355
			{
356
				$ftp_root = substr(BOARDDIR, 0, -strlen($path));
357
358
				// Avoid double//slash entries
359
				if (str_ends_with($ftp_root, '/') && (str_starts_with($path, '/')))
360
				{
361
					$ftp_root = substr($ftp_root, 0, -1);
362
				}
363
			}
364
			else
365
			{
366
				$ftp_root = BOARDDIR;
367
			}
368
369
			$_SESSION['ftp_connection'] = [
370
				'server' => $server,
371
				'port' => $port,
372
				'username' => $username,
373
				'password' => $this->packageCrypt($password),
374
				'path' => $path,
375
				'root' => rtrim($ftp_root, '\/'),
376
				'connected' => true,
377
				'error' => empty($ftp_error) ? null : $ftp_error,
378
			];
379
380
			if (!isset($this->_modSettings['package_path']) || $this->_modSettings['package_path'] !== $path)
381
			{
382
				updateSettings(['package_path' => $path]);
383
			}
384
385
			// This is now the primary connection.
386
			$package_ftp = $ftp;
387
		}
388
389
		return $ftp;
390
	}
391
392
	/**
393
	 * Try to make a file writable using PHP and/or FTP if available
394
	 *
395
	 * @param string $filename
396
	 * @param bool $track_change = false
397
	 *
398
	 * @return bool True if it worked, false if it didn't
399
	 * @package Packages
400
	 */
401
	public function pkgChmod($filename, $track_change = false): bool
402
	{
403
		global $package_ftp;
404
405
		// File is already writable, easy
406
		if ($this->fileFunc->isWritable($filename))
407
		{
408
			return true;
409
		}
410
411
		// If we don't have FTP, see if we can get this done
412
		if (!isset($package_ftp) || $package_ftp === false)
413
		{
414
			return $this->chmodNoFTP($filename, $track_change);
415
		}
416
417
		// If we have FTP, then we take it for a spin
418
		if (!empty($_SESSION['ftp_connection']))
419
		{
420
			return $this->chmodWithFTP($filename, $track_change);
421
		}
422
423
		// Oh dear, we failed if we get here.
424
		return false;
425
	}
426
427
	/**
428
	 * Try to make a file writable using built-in PHP SplFileInfo() functions
429
	 *
430
	 * @param string $filename
431
	 * @param bool $track_change = false
432
	 * @return bool True if it worked, false if it didn't
433
	 */
434
	public function chmodNoFTP($filename, $track_change): bool
435
	{
436
		$chmod_file = $filename;
437
438
		for ($i = 0; $i < 2; $i++)
439
		{
440
			// Start off with a less aggressive test.
441
			if ($i === 0)
442
			{
443
				// If this file doesn't exist, then we actually want to look at whatever parent directory does.
444
				$subTraverseLimit = 2;
445
				while (!$this->fileFunc->fileExists($chmod_file) && $subTraverseLimit)
446
				{
447
					$chmod_file = dirname($chmod_file);
448
					$subTraverseLimit--;
449
				}
450
451
				// Keep track of the writable status here.
452
				$file_permissions = $this->fileFunc->filePerms($chmod_file);
453
			}
454
			elseif (!$this->fileFunc->fileExists($chmod_file))
455
			{
456
				// This looks odd, but it's an attempt to work around PHP suExec.
457
				$file_permissions = $this->fileFunc->filePerms(dirname($chmod_file));
458
				mktree(dirname($chmod_file));
459
				@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

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