Packages::pre_dispatch()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 11
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 5
dl 0
loc 11
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 0
1
<?php
2
3
/**
4
 * This file is the main Package Manager.
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 BBC\ParserWrapper;
20
use ElkArte\AbstractController;
21
use ElkArte\Action;
22
use ElkArte\Cache\Cache;
23
use ElkArte\Database\AbstractTable;
24
use ElkArte\EventManager;
25
use ElkArte\Exceptions\Exception;
26
use ElkArte\Helper\FileFunctions;
27
use ElkArte\Helper\Util;
28
use ElkArte\Languages\Txt;
29
use ElkArte\User;
30
use FilesystemIterator;
31
use UnexpectedValueException;
32
33
/**
34
 * This class is the administration package manager controller.
35
 * Its main job is to install/uninstall, allow to browse, packages.
36
 * In fact, just about everything is related to addon packages, including FTP connections when necessary.
37
 *
38
 * @package Packages
39
 */
40
class Packages extends AbstractController
41
{
42
	/** @var array|bool listing of files in a package */
43
	private $_extracted_files;
44
45
	/** @var int The id from the DB or an installed package */
46
	public $install_id;
47
48
	/** @var string[] Array of installed theme paths */
49
	public $theme_paths;
50
51
	/** @var array Array of files / directories that require permissions */
52
	public $chmod_files;
53
54
	/** @var string Filename of the package */
55
	private $_filename;
56
57
	/** @var string Base path of the package */
58
	private $_base_path;
59
60
	/** @var bool If this is an uninstallation pass or not */
61
	private $_uninstalling;
62
63
	/** @var bool If the package is installed, previously or not */
64
	private $_is_installed;
65
66
	/** @var FileFunctions */
67
	private $fileFunc;
68
69
	/**
70
	 * Pre-dispatch, called before other methods.
71
	 */
72
	public function pre_dispatch()
73
	{
74
		// Generic subs for this controller
75
		require_once(SUBSDIR . '/Package.subs.php');
76
77
		// Load all the basic stuff.
78
		Txt::load('Packages');
79
		theme()->getTemplates()->load('Packages');
80
		loadCSSFile('admin.css');
81
82
		$this->fileFunc = FileFunctions::instance();
83
	}
84
85
	/**
86
	 * Entry point, the default method of this controller.
87
	 *
88
	 * @event integrate_sa_packages
89
	 * @see AbstractController::action_index
90
	 */
91
	public function action_index()
92
	{
93
		global $txt, $context;
94
95
		// Admins-only!
96
		isAllowedTo('admin_forum');
97
98
		$context['page_title'] = $txt['package'];
99
100
		// Delegation makes the world... that is, the package manager go 'round.
101
		$subActions = [
102
			'browse' => [$this, 'action_browse'],
103
			'remove' => [$this, 'action_remove'],
104
			'list' => [$this, 'action_list'],
105
			'ftptest' => [$this, 'action_ftptest'],
106
			'install' => [$this, 'action_install'],
107
			'install2' => [$this, 'action_install2'],
108
			'uninstall' => [$this, 'action_install'],
109
			'uninstall2' => [$this, 'action_install2'],
110
			'options' => [$this, 'action_options'],
111
			'flush' => [$this, 'action_flush'],
112
			'examine' => [$this, 'action_examine'],
113
			'showoperations' => [$this, 'action_showoperations'],
114
			// The following two belong to PackageServers,
115
			// for UI's sake moved here at least temporarily
116
			'servers' => [
117
				'controller' => PackageServers::class,
118
				'function' => 'action_list'],
119
			'upload' => [
120
				'controller' => PackageServers::class,
121
				'function' => 'action_upload'],
122
		];
123
124
		// Set up action/subaction stuff.
125
		$action = new Action('packages');
126
127
		// Set up some tabs...
128
		$context[$context['admin_menu_name']]['object']->prepareTabData([
129
			'title' => 'package_manager',
130
			'description' => 'package_manager_desc',
131
			'class' => 'i-package',
132
		]);
133
134
		// Work out exactly who it is we are calling. call integrate_sa_packages
135
		$subAction = $action->initialize($subActions, 'browse');
136
137
		// Set up for the template
138
		$context['sub_action'] = $subAction;
139
140
		// Let's just do it!
141
		$action->dispatch($subAction);
142
	}
143
144
	/**
145
	 * Test install/uninstall a package.
146
	 */
147
	public function action_install(): void
148
	{
149
		global $txt, $context;
150
151
		// You have to specify a file!!
152
		$file = $this->_req->getQuery('package', 'trim');
153
		if (empty($file))
154
		{
155
			redirectexit('action=admin;area=packages');
156
		}
157
158
		// What are we trying to do?
159
		$this->_filename = (string) preg_replace('~[.]+~', '.', $file);
160
		$this->_uninstalling = $this->_req->query->sa === 'uninstall';
161
162
		// If we can't find the file, our installation ends here
163
		if (!$this->fileFunc->fileExists(BOARDDIR . '/packages/' . $this->_filename))
164
		{
165
			throw new Exception('package_no_file', false);
166
		}
167
168
		// Do we have an existing id, for uninstallations and the like?
169
		$this->install_id = $this->_req->getQuery('pid', 'intval', 0);
170
171
		// This will be needed
172
		require_once(SUBSDIR . '/Themes.subs.php');
173
174
		// Load up the package FTP information?
175
		$create_chmod_control = new PackageChmod();
176
		$create_chmod_control->createChmodControl();
177
178
		// Make sure our temp directory exists and is empty.
179
		if ($this->fileFunc->isDir(BOARDDIR . '/packages/temp'))
180
		{
181
			deltree(BOARDDIR . '/packages/temp', false);
182
		}
183
		else
184
		{
185
			$this->_create_temp_dir();
186
		}
187
188
		// Extract the files in to the temp, so we can get things like the readme, etc.
189
		$this->_extract_files_temp();
190
191
		// Load up any custom themes we may want to install into...
192
		$this->theme_paths = getThemesPathbyID();
193
194
		// Get the package info...
195
		$packageInfo = getPackageInfo($this->_filename);
196
		if (!is_array($packageInfo))
197
		{
198
			throw new Exception($packageInfo);
199
		}
200
201
		$packageInfo['filename'] = $this->_filename;
202
203
		// The addon isn't installed... unless proven otherwise.
204
		$this->_is_installed = false;
205
206
		// See if it is installed?
207
		$package_installed = isPackageInstalled($packageInfo['id'], $this->install_id);
208
209
		// Any database actions
210
		$this->determineDatabaseChanges($packageInfo, $package_installed);
211
212
		$actions = $this->_get_package_actions($package_installed, $packageInfo);
213
		$context['actions'] = [];
214
		$context['ftp_needed'] = false;
215
216
		// No actions found, return so we can display an error
217
		if (empty($actions))
218
		{
219
			redirectexit('action=admin;area=packages');
220
		}
221
222
		// Now prepare things for the template using the package actions class
223
		$pka = new PackageActions(new EventManager());
224
		$pka->setUser(User::$info);
225
		$pka->test_init($actions, $this->_uninstalling, $this->_base_path, $this->theme_paths);
226
227
		$context['has_failure'] = $pka->has_failure;
228
		$context['failure_details'] = $pka->failure_details;
229
		$context['actions'] = $pka->ourActions;
230
231
		// Change our last link tree item for more information on this Packages area.
232
		$context['breadcrumbs'][count($context['breadcrumbs']) - 1] = [
233
			'url' => getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => 'browse']),
234
			'name' => $this->_uninstalling ? $txt['package_uninstall_actions'] : $txt['install_actions']
235
		];
236
237
		// All things to make the template go round
238
		$context['page_title'] .= ' - ' . ($this->_uninstalling ? $txt['package_uninstall_actions'] : $txt['install_actions']);
239
		$context['sub_template'] = 'view_package';
240
		$context['filename'] = $this->_filename;
241
		$context['package_name'] = $packageInfo['name'] ?? $this->_filename;
242
		$context['is_installed'] = $this->_is_installed;
243
		$context['uninstalling'] = $this->_uninstalling;
244
		$context['extract_type'] = $packageInfo['type'] ?? 'modification';
245
246
		// Have we got some things that we might want to do "multi-theme"?
247
		$this->_multi_theme($pka->themeFinds['candidates']);
248
249
		// Trash the cache... which will also check permissions for us!
250
		package_flush_cache(true);
251
252
		// Clear the temp directory
253
		if ($this->fileFunc->isDir(BOARDDIR . '/packages/temp'))
254
		{
255
			deltree(BOARDDIR . '/packages/temp');
256
		}
257
258
		// Will we require chmod permissions to pull this off?
259
		$this->chmod_files = empty($pka->chmod_files) ? [] : $pka->chmod_files;
260
		if (!empty($this->chmod_files))
261
		{
262
			$chmod_control = new PackageChmod();
263
			$ftp_status = $chmod_control->createChmodControl($this->chmod_files);
264
			$context['ftp_needed'] = !empty($ftp_status['files']['notwritable']) && !empty($context['package_ftp']);
265
		}
266
267
		$context['post_url'] = getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => ($this->_uninstalling ? 'uninstall' : 'install') . ($context['ftp_needed'] ? '' : '2'), 'package' => $this->_filename, 'pid' => $this->install_id]);
268
		checkSubmitOnce('register');
269
	}
270
271
	/**
272
	 * Creates the packages temp directory
273
	 *
274
	 * - First try as 755, failing moves to 777
275
	 * - Will try with FTP permissions for cases where the web server credentials
276
	 * do not have "create" directory permissions
277
	 *
278
	 * @throws Exception when no directory can be made
279
	 */
280
	private function _create_temp_dir(): void
281
	{
282
		global $context, $scripturl, $package_ftp;
283
284
		// Try to Make the temp directory
285
		if (!mktree(BOARDDIR . '/packages/temp'))
286
		{
287
			deltree(BOARDDIR . '/packages/temp', false);
288
			$chmod_control = new PackageChmod();
289
			$chmod_control->createChmodControl(
290
				[BOARDDIR . '/packages/temp/delme.tmp'],
291
				[
292
					'destination_url' => $scripturl . '?action=admin;area=packages;sa=' . $this->_req->query->sa . ';package=' . ($context['filename'] ?? ''),
293
					'crash_on_error' => true
294
				]
295
			);
296
297
			// No temp directory was able to be made, that's fatal
298
			deltree(BOARDDIR . '/packages/temp', false);
299
			unset($package_ftp, $_SESSION['ftp_connection']);
300
			throw new Exception('package_cant_download', false);
301
		}
302
	}
303
304
	/**
305
	 * Extracts a package file in the packages/temp directory
306
	 *
307
	 * - Sets the base path as needed
308
	 * - Loads $extracted_files with the package file listing
309
	 */
310
	private function _extract_files_temp(): void
311
	{
312
		// Is it a file in the package directory?
313
		if (is_file(BOARDDIR . '/packages/' . $this->_filename))
314
		{
315
			// Unpack the files in to the packages/temp directory
316
			$this->_extracted_files = read_tgz_file(BOARDDIR . '/packages/' . $this->_filename, BOARDDIR . '/packages/temp');
317
318
			// Determine the base path for the package
319
			if ($this->_extracted_files && !$this->fileFunc->fileExists(BOARDDIR . '/packages/temp/package-info.xml'))
320
			{
321
				foreach ($this->_extracted_files as $file)
322
				{
323
					if (basename($file['filename']) === 'package-info.xml')
324
					{
325
						$this->_base_path = dirname($file['filename']) . '/';
326
						break;
327
					}
328
				}
329
			}
330
331
			if ($this->_base_path === null)
332
			{
333
				$this->_base_path = '';
334
			}
335
		}
336
		// Perhaps it's a directory then, assumed to be extracted
337
		elseif (!empty($this->_filename) && $this->fileFunc->isDir(BOARDDIR . '/packages/' . $this->_filename))
338
		{
339
			// Copy the directory to the temp directory
340
			copytree(BOARDDIR . '/packages/' . $this->_filename, BOARDDIR . '/packages/temp');
341
342
			// Get the file listing
343
			$this->_extracted_files = $this->fileFunc->listtree(BOARDDIR . '/packages/temp');
344
			$this->_base_path = '';
345
		}
346
		// Well, we don't know what it is then, so we stop
347
		else
348
		{
349
			throw new Exception('no_access', false);
350
		}
351
	}
352
353
	/**
354
	 * Returns the actions that are required to install / uninstall / upgrade a package.
355
	 * Actions are defined by parsePackageInfo
356
	 * Sets the is_installed flag
357
	 *
358
	 * @param array $package_installed
359
	 * @param array $packageInfo Details for the package being tested/installed, set by getPackageInfo
360
	 * @param bool $testing passed to parsePackageInfo, true for test install, false for real installation
361
	 *
362
	 * @return array
363
	 * @throws Exception package_cant_uninstall, package_uninstall_cannot
364
	 */
365
	private function _get_package_actions($package_installed, $packageInfo, $testing = true): array
366
	{
367
		global $context;
368
369
		$actions = [];
370
371
		// Uninstalling?
372
		if ($this->_uninstalling)
373
		{
374
			// Wait, it's not installed yet!
375
			if (!isset($package_installed['old_version']))
376
			{
377
				deltree(BOARDDIR . '/packages/temp');
378
				throw new Exception('package_cant_uninstall', false);
379
			}
380
381
			$parser = new PackageParser();
382
			$actions = $parser->parsePackageInfo($packageInfo['xml'], $testing, 'uninstall');
383
384
			// Gadzooks!  There's no uninstaller at all!?
385
			if (empty($actions))
386
			{
387
				deltree(BOARDDIR . '/packages/temp');
388
				throw new Exception('package_uninstall_cannot', false);
389
			}
390
391
			// Can't edit the custom themes it's edited if you're uninstalling, they must be removed.
392
			$context['themes_locked'] = true;
393
394
			// Only let them uninstall themes it was installed into.
395
			foreach (array_keys($this->theme_paths) as $id)
396
			{
397
				if ($id === 1)
398
				{
399
					continue;
400
				}
401
402
				if (in_array($id, $package_installed['old_themes']))
403
				{
404
					continue;
405
				}
406
407
				unset($this->theme_paths[$id]);
408
			}
409
		}
410
		// Or is it already installed and you want to upgrade?
411
		elseif (isset($package_installed['old_version']) && $package_installed['old_version'] != $packageInfo['version'])
412
		{
413
			// Look for an upgrade...
414
			$parser = new PackageParser();
415
			$actions = $parser->parsePackageInfo($packageInfo['xml'], $testing, 'upgrade', $package_installed['old_version']);
416
417
			// There was no upgrade...
418
			if (empty($actions))
419
			{
420
				$this->_is_installed = true;
421
			}
422
			else
423
			{
424
				// Otherwise they can only upgrade themes from the first time around.
425
				foreach (array_keys($this->theme_paths) as $id)
426
				{
427
					if ($id === 1)
428
					{
429
						continue;
430
					}
431
432
					if (in_array($id, $package_installed['old_themes']))
433
					{
434
						continue;
435
					}
436
437
					unset($this->theme_paths[$id]);
438
				}
439
			}
440
		}
441
		// Simply already installed
442
		elseif (isset($package_installed['old_version']) && $package_installed['old_version'] == $packageInfo['version'])
443
		{
444
			$this->_is_installed = true;
445
		}
446
447
		if (!isset($package_installed['old_version']) || $this->_is_installed)
448
		{
449
			$parser = new PackageParser();
450
			$actions = $parser->parsePackageInfo($packageInfo['xml'], $testing);
451
		}
452
453
		return $actions;
454
	}
455
456
	/**
457
	 * Determines the availability / validity of installing a package in any of the installed themes
458
	 *
459
	 * @param array $themeFinds
460
	 */
461
	private function _multi_theme($themeFinds): void
462
	{
463
		global $settings, $txt, $context;
464
465
		if (!empty($themeFinds['candidates']))
466
		{
467
			foreach ($themeFinds['candidates'] as $action_data)
468
			{
469
				// Get the part of the file we'll be dealing with.
470
				preg_match('~^\$(languagedir|languages_dir|imagesdir|themedir)(\\|/)*(.+)*~i', $action_data['unparsed_destination'], $matches);
471
				$path = '';
472
				if ($matches[1] === 'imagesdir')
473
				{
474
					$path = '/' . basename($settings['default_images_url']);
475
				}
476
				elseif ($matches[1] === 'languagedir' || $matches[1] === 'languages_dir')
477
				{
478
					$path = '/ElkArte/Languages';
479
				}
480
481
				if (!empty($matches[3]))
482
				{
483
					$path .= $matches[3];
484
				}
485
486
				if (!$this->_uninstalling)
487
				{
488
					$path .= '/' . basename($action_data['filename']);
489
				}
490
491
				// Loop through each custom theme to note its candidacy!
492
				foreach ($this->theme_paths as $id => $theme_data)
493
				{
494
					$id = (int) $id;
495
					if (isset($theme_data['theme_dir']) && $id !== 1)
496
					{
497
						$real_path = $theme_data['theme_dir'] . $path;
498
499
						// Confirm that we don't already have this dealt with by another entry.
500
						if (!in_array(strtolower(strtr($real_path, ['\\' => '/'])), $themeFinds['other_themes'], true))
501
						{
502
							// Check if we will need to chmod this.
503
							if (!dirTest(dirname($real_path)))
504
							{
505
								$temp = dirname($real_path);
506
								while (!$this->fileFunc->fileExists($temp) && strlen($temp) > 1)
507
								{
508
									$temp = dirname($temp);
509
								}
510
511
								$this->chmod_files[] = $temp;
512
							}
513
514
							if ($action_data['type'] === 'require-dir'
515
								&& !$this->fileFunc->isWritable($real_path)
516
								&& ($this->fileFunc->fileExists($real_path) || !$this->fileFunc->isWritable(dirname($real_path))))
517
							{
518
								$this->chmod_files[] = $real_path;
519
							}
520
521
							if (!isset($context['theme_actions'][$id]))
522
							{
523
								$context['theme_actions'][$id] = [
524
									'name' => $theme_data['name'],
525
									'actions' => [],
526
								];
527
							}
528
529
							if ($this->_uninstalling)
530
							{
531
								$context['theme_actions'][$id]['actions'][] = [
532
									'type' => $txt['package_delete'] . ' ' . ($action_data['type'] === 'require-dir' ? $txt['package_tree'] : $txt['package_file']),
533
									'action' => strtr($real_path, ['\\' => '/', BOARDDIR => '.']),
534
									'description' => '',
535
									'value' => base64_encode(json_encode(['type' => $action_data['type'], 'orig' => $action_data['filename'], 'future' => $real_path, 'id' => $id])),
536
									'not_mod' => true,
537
								];
538
							}
539
							else
540
							{
541
								$context['theme_actions'][$id]['actions'][] = [
542
									'type' => $txt['package_extract'] . ' ' . ($action_data['type'] === 'require-dir' ? $txt['package_tree'] : $txt['package_file']),
543
									'action' => strtr($real_path, ['\\' => '/', BOARDDIR => '.']),
544
									'description' => '',
545
									'value' => base64_encode(json_encode(['type' => $action_data['type'], 'orig' => $action_data['destination'], 'future' => $real_path, 'id' => $id])),
546
									'not_mod' => true,
547
								];
548
							}
549
						}
550
					}
551
				}
552
			}
553
		}
554
	}
555
556
	/**
557
	 * Actually installs/uninstalls a package
558
	 */
559
	public function action_install2(): void
560
	{
561
		global $txt, $context, $modSettings;
562
563
		// Make sure we don't install this addon twice.
564
		checkSubmitOnce('check');
565
		checkSession();
566
567
		// If there's no package file, what are we installing?
568
		$this->_filename = $this->_req->getQuery('package', 'trim');
569
		if (empty($this->_filename))
570
		{
571
			redirectexit('action=admin;area=packages');
572
		}
573
574
		// And if the file does not exist, there is a problem
575
		if (!$this->fileFunc->fileExists(BOARDDIR . '/packages/' . $this->_filename))
576
		{
577
			throw new Exception('package_no_file', false);
578
		}
579
580
		// If this is an uninstallation, we'll have an id.
581
		$this->install_id = $this->_req->getQuery('pid', 'intval', 0);
582
583
		// Installing in themes will require some help
584
		require_once(SUBSDIR . '/Themes.subs.php');
585
586
		$this->_uninstalling = $this->_req->query->sa === 'uninstall2';
587
588
		// Load up the package FTP information?
589
		$chmod_control = new PackageChmod();
590
		$chmod_control->createChmodControl(
591
			[],
592
			[
593
				'destination_url' => getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => $this->_req->query->sa, 'package' => $this->_req->query->package])
594
			]
595
		);
596
597
		// Make sure the temp directory exists and is empty!
598
		if ($this->fileFunc->isDir(BOARDDIR . '/packages/temp'))
599
		{
600
			deltree(BOARDDIR . '/packages/temp', false);
601
		}
602
		else
603
		{
604
			$this->_create_temp_dir();
605
		}
606
607
		// Let the unpacker do the work.
608
		$this->_extract_files_temp();
609
610
		// Are we installing this into any custom themes?
611
		$custom_themes = $this->_getCustomThemes();
612
613
		// Now load up the paths of the themes that we need to know about.
614
		$this->theme_paths = getThemesPathbyID($custom_themes);
615
616
		// Is there any theme copying that we want to take place?
617
		$themes_installed = $this->_installThemes();
618
619
		// Get the package info...
620
		$packageInfo = getPackageInfo($this->_filename);
621
		if (!is_array($packageInfo))
622
		{
623
			throw new Exception($packageInfo);
624
		}
625
626
		$packageInfo['filename'] = $this->_filename;
627
628
		$context['base_path'] = $this->_base_path;
629
		$context['extracted_files'] = $this->_extracted_files;
630
631
		// Create a backup file to roll back to! (but if they do this more than once, don't run it a zillion times.)
632
		if (!empty($modSettings['package_make_full_backups']) && (!isset($_SESSION['last_backup_for']) || $_SESSION['last_backup_for'] != $this->_filename . ($this->_uninstalling ? '$$' : '$')))
633
		{
634
			$_SESSION['last_backup_for'] = $this->_filename . ($this->_uninstalling ? '$$' : '$');
635
636
			package_create_backup(($this->_uninstalling ? 'backup_' : 'before_') . strtok($this->_filename, '.'));
637
		}
638
639
		// The addon isn't installed... unless proven otherwise.
640
		$this->_is_installed = false;
641
642
		// Is it actually installed?
643
		$package_installed = isPackageInstalled($packageInfo['id'], $this->install_id);
644
645
		// Fetch the installation status and action log
646
		$install_log = $this->_get_package_actions($package_installed, $packageInfo, false);
647
648
		// Set up the details for the sub template, breadcrumbs, etc.
649
		$context['breadcrumbs'][count($context['breadcrumbs']) - 1] = [
650
			'url' => getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => 'browse']),
651
			'name' => $this->_uninstalling ? $txt['uninstall'] : $txt['extracting']
652
		];
653
		$context['page_title'] .= ' - ' . ($this->_uninstalling ? $txt['uninstall'] : $txt['extracting']);
654
		$context['sub_template'] = 'extract_package';
655
		$context['filename'] = $this->_filename;
656
		$context['install_finished'] = false;
657
		$context['is_installed'] = $this->_is_installed;
658
		$context['uninstalling'] = $this->_uninstalling;
659
		$context['extract_type'] = $packageInfo['type'] ?? 'modification';
660
661
		// We're gonna be needing the table db functions! ...Sometimes.
662
		$table_installer = db_table();
663
664
		// @todo Make a log of any errors that occurred and output them?
665
		if (!empty($install_log))
666
		{
667
			$pka = new PackageActions(new EventManager());
668
			$pka->setUser(User::$info);
669
			$pka->install_init($install_log, $this->_uninstalling, $this->_base_path, $this->theme_paths, $themes_installed);
670
			$failed_steps = $pka->failed_steps;
671
			$themes_installed = $pka->themes_installed;
672
673
			package_flush_cache();
674
675
			// First, ensure this change doesn't get removed by putting a stake in the ground (So to speak).
676
			package_put_contents(BOARDDIR . '/packages/installed.list', time());
677
678
			// See if this is already installed
679
			$is_upgrade = false;
680
			$old_db_changes = [];
681
			$package_check = isPackageInstalled($packageInfo['id']);
682
683
			// Change the installed state as required.
684
			if (!empty($package_check['install_state']))
685
			{
686
				if ($this->_uninstalling)
687
				{
688
					setPackageState($package_check['package_id'], $this->install_id);
689
				}
690
				else
691
				{
692
					// not uninstalling so must be an upgrade
693
					$is_upgrade = true;
694
					$old_db_changes = empty($package_check['db_changes']) ? [] : $package_check['db_changes'];
695
				}
696
			}
697
698
			// Assuming we're not uninstalling, add the entry.
699
			if (!$this->_uninstalling)
700
			{
701
				// Any db changes from an older version?
702
				$table_log = $table_installer->package_log();
703
704
				if (!empty($old_db_changes))
705
				{
706
					$db_package_log = empty($table_log) ? $old_db_changes : array_merge($old_db_changes, $table_log);
707
				}
708
				else
709
				{
710
					$db_package_log = $table_log;
711
				}
712
713
				// If there are some database changes we might want to remove then filter them out.
714
				if (!empty($db_package_log))
715
				{
716
					// We're really just checking for entries which are creating table AND add columns (etc.).
717
					$tables = [];
718
					usort($db_package_log, fn(array $a, array $b): int => $this->_sort_table_first($a, $b));
719
					foreach ($db_package_log as $k => $log)
720
					{
721
						if ($log[0] === 'remove_table')
722
						{
723
							$tables[] = $log[1];
724
						}
725
						elseif (in_array($log[1], $tables, true))
726
						{
727
							unset($db_package_log[$k]);
728
						}
729
					}
730
731
					$package_installed['db_changes'] = serialize($db_package_log);
732
				}
733
				else
734
				{
735
					$package_installed['db_changes'] = '';
736
				}
737
738
				// What themes did we actually install?
739
				$themes_installed = array_unique($themes_installed);
740
				$themes_installed = implode(',', $themes_installed);
741
742
				// What failed steps?
743
				$failed_step_insert = serialize($failed_steps);
744
745
				// Credits tag?
746
				$credits_tag = (empty($pka->credits_tag)) ? '' : serialize($pka->credits_tag);
747
748
				// Add to the log packages
749
				addPackageLog($packageInfo, $failed_step_insert, $themes_installed, $package_installed['db_changes'], $is_upgrade, $credits_tag);
750
			}
751
752
			$context['install_finished'] = true;
753
		}
754
755
		// If there are database changes - and they want them removed - let's do it last!
756
		$this->removeDatabaseChanges($package_installed, $table_installer);
757
758
		// Clean house... get rid of the evidence ;).
759
		if ($this->fileFunc->isDir(BOARDDIR . '/packages/temp'))
760
		{
761
			deltree(BOARDDIR . '/packages/temp');
762
		}
763
764
		// Log what we just did.
765
		logAction($this->_uninstalling ? 'uninstall_package' : (empty($is_upgrade) ? 'install_package' : 'upgrade_package'), ['package' => Util::htmlspecialchars($packageInfo['name']), 'version' => Util::htmlspecialchars($packageInfo['version'])], 'admin');
766
767
		// Just in case, let's clear the whole cache to avoid anything going up the swanny.
768
		Cache::instance()->clean();
769
770
		// Restore file permissions?
771
		$chmod_control = new PackageChmod();
772
		$chmod_control->createChmodControl([], [], true);
773
	}
774
775
	/**
776
	 * Get Custom Themes
777
	 *
778
	 * This method extracts custom themes from the requests and validates them.
779
	 *
780
	 * @return array
781
	 */
782
	private function _getCustomThemes(): array
783
	{
784
		global $modSettings;
785
786
		$custom_themes = [1];
787
		$known_themes = explode(',', $modSettings['knownThemes']);
788
		$known_themes = array_map('intval', $known_themes);
789
790
		if (!empty($this->_req->post->custom_theme))
791
		{
792
			// Normalize to array to avoid invalid foreach or string offset issues
793
			$custom_theme = $this->_req->post->custom_theme;
794
			if (!is_array($custom_theme))
795
			{
796
				$custom_theme = [$custom_theme];
797
			}
798
799
			foreach ($custom_theme as $tid)
800
			{
801
				if (in_array($tid, $known_themes, true))
802
				{
803
					$custom_themes[] = $tid;
804
				}
805
			}
806
		}
807
808
		return $custom_themes;
809
	}
810
811
	/**
812
	 * Install Themes
813
	 *
814
	 * This method installs the custom themes.
815
	 *
816
	 * @return array
817
	 */
818
	private function _installThemes(): array
819
	{
820
		global $context;
821
822
		$themes_installed = [1];
823
824
		$context['theme_copies'] = [
825
			'require-file' => [],
826
			'require-dir' => [],
827
		];
828
829
		if (!empty($this->_req->post->theme_changes))
830
		{
831
			// Normalize to array to avoid invalid foreach or string offset issues
832
			$theme_changes = $this->_req->post->theme_changes;
833
			if (!is_array($theme_changes))
834
			{
835
				$theme_changes = [$theme_changes];
836
			}
837
838
			foreach ($theme_changes as $change)
839
			{
840
				if (empty($change))
841
				{
842
					continue;
843
				}
844
845
				$theme_data = json_decode(base64_decode($change), true);
846
847
				if (empty($theme_data['type']))
848
				{
849
					continue;
850
				}
851
852
				$themes_installed[] = (int) $theme_data['id'];
853
				$context['theme_copies'][$theme_data['type']][$theme_data['orig']][] = $theme_data['future'];
854
			}
855
		}
856
857
		return $themes_installed;
858
	}
859
860
	/**
861
	 * List the files in a package.
862
	 */
863
	public function action_list(): void
864
	{
865
		global $txt, $context;
866
867
		// No package?  Show him or her the door.
868
		$package = $this->_req->getQuery('package', 'trim', '');
869
		if (empty($package))
870
		{
871
			redirectexit('action=admin;area=packages');
872
		}
873
874
		$context['breadcrumbs'][] = [
875
			'url' => getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => 'list', 'package' => $package]),
876
			'name' => $txt['list_file']
877
		];
878
		$context['page_title'] .= ' - ' . $txt['list_file'];
879
		$context['sub_template'] = 'list';
880
881
		// The filename...
882
		$context['filename'] = $package;
883
884
		// Let the unpacker do the work.
885
		if (is_file(BOARDDIR . '/packages/' . $context['filename']))
886
		{
887
			$context['files'] = read_tgz_file(BOARDDIR . '/packages/' . $context['filename'], null);
888
		}
889
		elseif ($this->fileFunc->isDir(BOARDDIR . '/packages/' . $context['filename']))
890
		{
891
			$context['files'] = $this->fileFunc->listtree(BOARDDIR . '/packages/' . $context['filename']);
892
		}
893
	}
894
895
	/**
896
	 * Display one of the files in a package.
897
	 */
898
	public function action_examine(): void
899
	{
900
		global $txt, $context;
901
902
		// No package?  Show him or her the door.
903
		if (!$this->_req->hasQuery('package') || $this->_req->query->package == '')
904
		{
905
			redirectexit('action=admin;area=packages');
906
		}
907
908
		// No file?  Show him or her the door.
909
		if (empty($this->_req->getQuery('file', 'trim', '')))
910
		{
911
			redirectexit('action=admin;area=packages');
912
		}
913
914
		$this->_req->query->package = preg_replace('~[.]+~', '.', strtr($this->_req->query->package, ['/' => '_', '\\' => '_']));
915
		$this->_req->query->file = preg_replace('~[.]+~', '.', $this->_req->query->file);
916
917
		if ($this->_req->hasQuery('raw'))
918
		{
919
			if (is_file(BOARDDIR . '/packages/' . $this->_req->query->package))
920
			{
921
				echo read_tgz_file(BOARDDIR . '/packages/' . $this->_req->query->package, $this->_req->query->file, true);
0 ignored issues
show
Bug introduced by
Are you sure read_tgz_file(ElkArte\Pa...req->query->file, true) of type array|boolean can be used in echo? ( Ignorable by Annotation )

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

921
				echo /** @scrutinizer ignore-type */ read_tgz_file(BOARDDIR . '/packages/' . $this->_req->query->package, $this->_req->query->file, true);
Loading history...
922
			}
923
			elseif ($this->fileFunc->isDir(BOARDDIR . '/packages/' . $this->_req->query->package))
924
			{
925
				echo file_get_contents(BOARDDIR . '/packages/' . $this->_req->query->package . '/' . $this->_req->query->file);
926
			}
927
928
			obExit(false);
929
		}
930
931
		$context['breadcrumbs'][count($context['breadcrumbs']) - 1] = [
932
			'url' => getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => 'list', 'package' => $this->_req->query->package]),
933
			'name' => $txt['package_examine_file']
934
		];
935
		$context['page_title'] .= ' - ' . $txt['package_examine_file'];
936
		$context['sub_template'] = 'examine';
937
938
		// The filename...
939
		$context['package'] = $this->_req->query->package;
940
		$context['filename'] = $this->_req->query->file;
941
942
		// Let the unpacker do the work... but make sure we handle images properly.
943
		if (in_array(strtolower(strrchr($this->_req->query->file, '.')), ['.bmp', '.gif', '.jpeg', '.jpg', '.png']))
944
		{
945
			$context['filedata'] = '<img src="' . getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => 'examine', 'package' => $this->_req->query->package, 'file' => $this->_req->query->file, 'raw']) . '" alt="' . $this->_req->query->file . '" />';
946
		}
947
		elseif (is_file(BOARDDIR . '/packages/' . $this->_req->query->package))
948
		{
949
			$context['filedata'] = htmlspecialchars(read_tgz_file(BOARDDIR . '/packages/' . $this->_req->query->package, $this->_req->query->file, true));
0 ignored issues
show
Bug introduced by
read_tgz_file(ElkArte\Pa...req->query->file, true) of type array|boolean is incompatible with the type string expected by parameter $string of htmlspecialchars(). ( Ignorable by Annotation )

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

949
			$context['filedata'] = htmlspecialchars(/** @scrutinizer ignore-type */ read_tgz_file(BOARDDIR . '/packages/' . $this->_req->query->package, $this->_req->query->file, true));
Loading history...
950
		}
951
		elseif ($this->fileFunc->isDir(BOARDDIR . '/packages/' . $this->_req->query->package))
952
		{
953
			$context['filedata'] = htmlspecialchars(file_get_contents(BOARDDIR . '/packages/' . $this->_req->query->package . '/' . $this->_req->query->file));
954
		}
955
	}
956
957
	/**
958
	 * Empty out the installed list.
959
	 */
960
	public function action_flush(): void
961
	{
962
		// Always check the session.
963
		checkSession('get');
964
965
		include_once(SUBSDIR . '/Package.subs.php');
966
967
		// Record when we last did this.
968
		package_put_contents(BOARDDIR . '/packages/installed.list', time());
969
970
		// Set everything as uninstalled.
971
		setPackagesAsUninstalled();
972
973
		redirectexit('action=admin;area=packages;sa=installed');
974
	}
975
976
	/**
977
	 * Delete a package.
978
	 */
979
	public function action_remove(): void
980
	{
981
		// Check it.
982
		checkSession('get');
983
984
		// Ack, don't allow deletion of arbitrary files here, could become a security hole somehow!
985
		$check = $this->_req->getQuery('package', 'trim', '');
986
		if (empty($check) || $check === 'index.php' || $check === 'installed.list' || $check === 'backups')
987
		{
988
			redirectexit('action=admin;area=packages;sa=browse');
989
		}
990
991
		$check = preg_replace('~[\.]+~', '.', strtr($check, ['/' => '_', '\\' => '_']));
992
993
		// Can't delete what's not there.
994
		if ($this->fileFunc->fileExists(BOARDDIR . '/packages/' . $check)
995
			&& (str_ends_with($check, '.zip')
996
				|| str_ends_with($check, '.tgz')
997
				|| str_ends_with($check, '.tar.gz')
998
				|| $this->fileFunc->isDir(BOARDDIR . '/packages/' . $check))
999
			&& $check !== 'backups'
1000
			&& $check[0] !== '.')
1001
		{
1002
			$chmod_control = new PackageChmod();
1003
			$chmod_control->createChmodControl(
1004
				[BOARDDIR . '/packages/' . $check],
1005
				[
1006
					'destination_url' => getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => 'remove', 'package' => $check]),
1007
					'crash_on_error' => true
1008
				]
1009
			);
1010
1011
			if ($this->fileFunc->isDir(BOARDDIR . '/packages/' . $check))
1012
			{
1013
				deltree(BOARDDIR . '/packages/' . $check);
1014
			}
1015
			else
1016
			{
1017
				$this->fileFunc->chmod(BOARDDIR . '/packages/' . $check);
1018
				$this->fileFunc->delete(BOARDDIR . '/packages/' . $check);
1019
			}
1020
		}
1021
1022
		redirectexit('action=admin;area=packages;sa=browse');
1023
	}
1024
1025
	/**
1026
	 * Browse a list of packages.
1027
	 */
1028
	public function action_browse(): void
1029
	{
1030
		global $txt, $context;
1031
1032
		$context['page_title'] .= ' - ' . $txt['browse_packages'];
1033
		$context['forum_version'] = FORUM_VERSION;
1034
		$context['available_addon'] = [];
1035
		$context['available_avatar'] = [];
1036
		$context['available_smiley'] = [];
1037
		$context['available_language'] = [];
1038
		$context['available_unknown'] = [];
1039
1040
		$context['package_types'] = ['addon', 'avatar', 'language', 'smiley', 'unknown'];
1041
1042
		call_integration_hook('integrate_package_types');
1043
1044
		foreach ($context['package_types'] as $type)
1045
		{
1046
			// Use the standard templates for showing this.
1047
			$listOptions = [
1048
				'id' => 'packages_lists_' . $type,
1049
				'title' => $txt[($type === 'addon' ? 'modification' : $type) . '_package'],
1050
				'no_items_label' => $txt['no_packages'],
1051
				'get_items' => [
1052
					'function' => fn(int $start, int $items_per_page, string $sort, string $params) => $this->list_packages($start, $items_per_page, $sort, $params),
1053
					'params' => ['params' => $type],
1054
				],
1055
				'base_href' => getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => $context['sub_action'], 'type' => $type]),
1056
				'default_sort_col' => 'pkg_name' . $type,
1057
				'columns' => [
1058
					'pkg_name' . $type => [
1059
						'header' => [
1060
							'value' => $txt['mod_name'],
1061
							'style' => 'width: 25%;',
1062
						],
1063
						'data' => [
1064
							'function' => static function ($package_md5) use ($type) {
1065
								global $context;
1066
1067
								if (isset($context['available_' . $type][$package_md5]))
1068
								{
1069
									return $context['available_' . $type][$package_md5]['name'];
1070
								}
1071
1072
								return '';
1073
							},
1074
						],
1075
						'sort' => [
1076
							'default' => 'name',
1077
							'reverse' => 'name',
1078
						],
1079
					],
1080
					'version' . $type => [
1081
						'header' => [
1082
							'value' => $txt['mod_version'],
1083
							'style' => 'width: 25%;',
1084
						],
1085
						'data' => [
1086
							'function' => static function ($package_md5) use ($type) {
1087
								global $context;
1088
1089
								if (isset($context['available_' . $type][$package_md5]))
1090
								{
1091
									return $context['available_' . $type][$package_md5]['version'];
1092
								}
1093
1094
								return '';
1095
							},
1096
						],
1097
						'sort' => [
1098
							'default' => 'version',
1099
							'reverse' => 'version',
1100
						],
1101
					],
1102
					'time_installed' . $type => [
1103
						'header' => [
1104
							'value' => $txt['package_installed_on'],
1105
						],
1106
						'data' => [
1107
							'function' => static function ($package_md5) use ($type, $txt) {
1108
								global $context;
1109
1110
								if (!empty($context['available_' . $type][$package_md5]['time_installed']))
1111
								{
1112
									return htmlTime($context['available_' . $type][$package_md5]['time_installed']);
1113
								}
1114
1115
								return $txt['not_applicable'];
1116
							},
1117
						],
1118
						'sort' => [
1119
							'default' => 'time_installed',
1120
							'reverse' => 'time_installed',
1121
						],
1122
					],
1123
					'operations' . $type => [
1124
						'header' => [
1125
							'value' => '',
1126
						],
1127
						'data' => [
1128
							'function' => static function ($package_md5) use ($type) {
1129
								global $context, $txt;
1130
1131
								if (!isset($context['available_' . $type][$package_md5]))
1132
								{
1133
									return '';
1134
								}
1135
1136
								// Rewrite shortcut
1137
								$package = $context['available_' . $type][$package_md5];
1138
								$return = '';
1139
								if ($package['can_uninstall'])
1140
								{
1141
									$return = '
1142
										<a class="linkbutton" href="' . getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => 'uninstall', 'package' => $package['filename'], 'pid' => $package['installed_id']]) . '">' . $txt['uninstall'] . '</a>';
1143
								}
1144
								elseif ($package['can_emulate_uninstall'])
1145
								{
1146
									$return = '
1147
										<a class="linkbutton" href="' . getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => 'uninstall', 've' => $package['can_emulate_uninstall'], 'package' => $package['filename'], 'pid' => $package['installed_id']]) . '">' . $txt['package_emulate_uninstall'] . ' ' . $package['can_emulate_uninstall'] . '</a>';
1148
								}
1149
								elseif ($package['can_upgrade'])
1150
								{
1151
									$return = '
1152
										<a class="linkbutton" href="' . getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => 'install', 'package' => $package['filename']]) . '">' . $txt['package_upgrade'] . '</a>';
1153
								}
1154
								elseif ($package['can_install'])
1155
								{
1156
									$return = '
1157
										<a class="linkbutton" href="' . getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => 'install', 'package' => $package['filename']]) . '">' . $txt['install_mod'] . '</a>';
1158
								}
1159
								elseif ($package['can_emulate_install'])
1160
								{
1161
									$return = '
1162
										<a class="linkbutton" href="' . getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => 'install', 've' => $package['can_emulate_install'], 'package' => $package['filename']]) . '">' . $txt['package_emulate_install'] . ' ' . $package['can_emulate_install'] . '</a>';
1163
								}
1164
								return $return . '
1165
										<a class="linkbutton" href="' . getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => 'list', 'package' => $package['filename']]) . '">' . $txt['list_files'] . '</a>
1166
										<a class="linkbutton" href="' . getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => 'remove', 'package' => $package['filename'], '{session_data}']) . '"' . ($package['is_installed'] && $package['is_current']
1167
										? ' onclick="return confirm(\'' . $txt['package_delete_bad'] . '\');"'
1168
										: '') . '>' . $txt['package_delete'] . '</a>';
1169
							},
1170
							'class' => 'righttext',
1171
						],
1172
					],
1173
				],
1174
				'additional_rows' => [
1175
					[
1176
						'position' => 'bottom_of_list',
1177
						'class' => 'submitbutton',
1178
						'value' => ($context['sub_action'] === 'browse'
1179
							? ''
1180
							: '<a class="linkbutton" href="' . getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => 'flush', '{session_data}']) . '" onclick="return confirm(\'' . $txt['package_delete_list_warning'] . '\');">' . $txt['delete_list'] . '</a>'),
1181
					],
1182
				],
1183
			];
1184
1185
			createList($listOptions);
1186
		}
1187
1188
		$context['sub_template'] = 'browse';
1189
		$context['default_list'] = 'packages_lists';
1190
	}
1191
1192
	/**
1193
	 * Test an FTP connection via Ajax
1194
	 *
1195
	 * @uses Xml Template, generic_xml sub template
1196
	 */
1197
	public function action_ftptest(): void
1198
	{
1199
		global $context, $txt, $package_ftp;
1200
1201
		checkSession('get');
1202
1203
		// Try to make the FTP connection.
1204
		$chmod_control = new PackageChmod();
1205
		$chmod_control->createChmodControl([], ['force_find_error' => true]);
1206
1207
		// Deal with the template stuff.
1208
		theme()->getTemplates()->load('Xml');
1209
		$context['sub_template'] = 'generic_xml';
1210
		theme()->getLayers()->removeAll();
1211
1212
		// Define the return data, this is simple.
1213
		$context['xml_data'] = [
1214
			'results' => [
1215
				'identifier' => 'result',
1216
				'children' => [
1217
					[
1218
						'attributes' => [
1219
							'success' => empty($package_ftp) ? 0 : 1,
1220
						],
1221
						'value' => empty($package_ftp)
1222
							? $context['package_ftp']['error'] ?? $txt['package_ftp_test_failed']
1223
							: ($txt['package_ftp_test_success']),
1224
					],
1225
				],
1226
			],
1227
		];
1228
	}
1229
1230
	/**
1231
	 * Used when temp FTP access is needed to package functions
1232
	 */
1233
	public function action_options(): void
1234
	{
1235
		global $txt, $context, $modSettings;
1236
1237
		if ($this->_req->hasPost('save'))
1238
		{
1239
			checkSession();
1240
1241
			updateSettings([
1242
				'package_server' => $this->_req->getPost('pack_server', 'trim|Util::htmlspecialchars'),
1243
				'package_port' => $this->_req->getPost('pack_port', 'trim|Util::htmlspecialchars'),
1244
				'package_username' => $this->_req->getPost('pack_user', 'trim|Util::htmlspecialchars'),
1245
				'package_make_backups' => !empty($this->_req->post->package_make_backups),
1246
				'package_make_full_backups' => !empty($this->_req->post->package_make_full_backups)
1247
			]);
1248
1249
			redirectexit('action=admin;area=packages;sa=options');
1250
		}
1251
1252
		if (preg_match('~^/home\d*/([^/]+?)/public_html~', $this->_req->server->DOCUMENT_ROOT, $match))
1253
		{
1254
			$default_username = $match[1];
1255
		}
1256
		else
1257
		{
1258
			$default_username = '';
1259
		}
1260
1261
		$context['page_title'] = $txt['package_settings'];
1262
		$context['sub_template'] = 'install_options';
1263
		$context['package_ftp_server'] = $modSettings['package_server'] ?? 'localhost';
1264
		$context['package_ftp_port'] = $modSettings['package_port'] ?? '21';
1265
		$context['package_ftp_username'] = $modSettings['package_username'] ?? $default_username;
1266
		$context['package_make_backups'] = !empty($modSettings['package_make_backups']);
1267
		$context['package_make_full_backups'] = !empty($modSettings['package_make_full_backups']);
1268
	}
1269
1270
	/**
1271
	 * List operations
1272
	 */
1273
	public function action_showoperations(): void
1274
	{
1275
		global $context, $txt;
1276
1277
		// Can't be in here buddy.
1278
		isAllowedTo('admin_forum');
1279
1280
		$operation_key = $this->_req->getQuery('operation_key', 'trim');
1281
		$filename = $this->_req->getQuery('filename', 'trim');
1282
		$package = $this->_req->getQuery('package', 'trim');
1283
		$install_id = $this->_req->getQuery('install_id', 'intval', 0);
1284
1285
		// We need to know the operation key for the search and replace?
1286
		if (!isset($operation_key, $filename) && !is_numeric($operation_key))
1287
		{
1288
			throw new Exception('operation_invalid', 'general');
1289
		}
1290
1291
		// Load the required file.
1292
		require_once(SUBSDIR . '/Themes.subs.php');
1293
1294
		// Uninstalling the mod?
1295
		$reverse = $this->_req->hasQuery('reverse');
1296
1297
		// Get the base name.
1298
		$context['filename'] = preg_replace('~[\.]+~', '.', $package);
1299
1300
		// We need to extract this again.
1301
		if (is_file(BOARDDIR . '/packages/' . $context['filename']))
1302
		{
1303
			$this->_extracted_files = read_tgz_file(BOARDDIR . '/packages/' . $context['filename'], BOARDDIR . '/packages/temp');
1304
			if ($this->_extracted_files
1305
				&& !$this->fileFunc->fileExists(BOARDDIR . '/packages/temp/package-info.xml'))
1306
			{
1307
				foreach ($this->_extracted_files as $file)
1308
				{
1309
					if (basename($file['filename']) === 'package-info.xml')
1310
					{
1311
						$this->_base_path = dirname($file['filename']) . '/';
1312
						break;
1313
					}
1314
				}
1315
			}
1316
1317
			if ($this->_base_path === null)
1318
			{
1319
				$this->_base_path = '';
1320
			}
1321
		}
1322
		elseif ($this->fileFunc->isDir(BOARDDIR . '/packages/' . $context['filename']))
1323
		{
1324
			copytree(BOARDDIR . '/packages/' . $context['filename'], BOARDDIR . '/packages/temp');
1325
			$this->_extracted_files = $this->fileFunc->listtree(BOARDDIR . '/packages/temp');
1326
			$this->_base_path = '';
1327
		}
1328
1329
		$context['base_path'] = $this->_base_path;
1330
		$context['extracted_files'] = $this->_extracted_files;
1331
1332
		// Load up any custom themes we may want to install into...
1333
		$theme_paths = getThemesPathbyID();
1334
1335
		// For uninstall operations we only consider the themes in which the package is installed.
1336
		if ($reverse && !empty($install_id) && $install_id > 0)
1337
		{
1338
			$old_themes = loadThemesAffected($install_id);
1339
			foreach ($theme_paths as $id => $data)
1340
			{
1341
				if ((int) $id === 1)
1342
				{
1343
					continue;
1344
				}
1345
1346
				if (in_array($id, $old_themes))
1347
				{
1348
					continue;
1349
				}
1350
1351
				unset($theme_paths[$id]);
1352
			}
1353
		}
1354
1355
		$mod_actions = parseModification(@file_get_contents(BOARDDIR . '/packages/temp/' . $context['base_path'] . $this->_req->query->filename), true, $reverse, $theme_paths);
0 ignored issues
show
Bug introduced by
It seems like @file_get_contents(ElkAr...>_req->query->filename) can also be of type false; however, parameter $file of parseModification() 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

1355
		$mod_actions = parseModification(/** @scrutinizer ignore-type */ @file_get_contents(BOARDDIR . '/packages/temp/' . $context['base_path'] . $this->_req->query->filename), true, $reverse, $theme_paths);
Loading history...
1356
1357
		// Ok, let's get the content of the file.
1358
		$context['operations'] = [
1359
			'search' => strtr(htmlspecialchars($mod_actions[$operation_key]['search_original'], ENT_COMPAT), ['[' => '&#91;', ']' => '&#93;']),
1360
			'replace' => strtr(htmlspecialchars($mod_actions[$operation_key]['replace_original'], ENT_COMPAT), ['[' => '&#91;', ']' => '&#93;']),
1361
			'position' => $mod_actions[$operation_key]['position'],
1362
		];
1363
1364
		// Let's do some formatting...
1365
		$operation_text = $context['operations']['position'] === 'replace' ? 'operation_replace' : ($context['operations']['position'] === 'before' ? 'operation_after' : 'operation_before');
1366
		$bbc_parser = ParserWrapper::instance();
1367
		$context['operations']['search'] = $bbc_parser->parsePackage('[code=' . $txt['operation_find'] . ']' . ($context['operations']['position'] === 'end' ? '?&gt;' : $context['operations']['search']) . '[/code]');
1368
		$context['operations']['replace'] = $bbc_parser->parsePackage('[code=' . $txt[$operation_text] . ']' . $context['operations']['replace'] . '[/code]');
1369
1370
		// No layers
1371
		theme()->getLayers()->removeAll();
1372
		$context['sub_template'] = 'view_operations';
1373
	}
1374
1375
	/**
1376
	 * Get a listing of all the packages
1377
	 *
1378
	 * - Determines if the package is addon, smiley, avatar, language, or unknown package
1379
	 * - Determines if the package has been installed or not
1380
	 *
1381
	 * @param int $start The item to start with (for pagination purposes)
1382
	 * @param int $items_per_page The number of items to show per page
1383
	 * @param string $sort A string indicating how to sort the results
1384
	 * @param string $params 'type' type of package
1385
	 *
1386
	 * @return mixed
1387
	 * @throws Exception
1388
	 */
1389
	public function list_packages($start, $items_per_page, $sort, $params)
1390
	{
1391
		global $context;
1392
		static $instadds, $packages;
1393
1394
		// Start things up
1395
		if (!isset($packages[$params]))
1396
		{
1397
			$packages[$params] = [];
1398
		}
1399
1400
		// We need the packages directory to be writable for this.
1401
		if (!$this->fileFunc->isWritable(BOARDDIR . '/packages'))
1402
		{
1403
			$create_chmod_control = new PackageChmod();
1404
			$create_chmod_control->createChmodControl(
1405
				[BOARDDIR . '/packages'],
1406
				[
1407
					'destination_url' => getUrl('admin', ['action' => 'admin', 'area' => 'packages']),
1408
					'crash_on_error' => true
1409
				]
1410
			);
1411
		}
1412
1413
		[$the_brand, $the_version] = explode(' ', FORUM_VERSION, 2);
1414
1415
		// Here we have a little code to help those who class themselves as something of gods, version emulation ;)
1416
		$checkEmulate = $this->_req->getQuery('version_emulate', 'trim', '');
1417
		if (!empty($checkEmulate) && strtr($checkEmulate, [$the_brand => '']) === $the_version)
1418
		{
1419
			unset($_SESSION['version_emulate']);
1420
		}
1421
		elseif ($checkEmulate)
1422
		{
1423
			if (($checkEmulate === 0 || $checkEmulate === FORUM_VERSION) && isset($_SESSION['version_emulate']))
1424
			{
1425
				unset($_SESSION['version_emulate']);
1426
			}
1427
			elseif ($checkEmulate !== 0)
1428
			{
1429
				$_SESSION['version_emulate'] = strtr($checkEmulate, ['-' => ' ', '+' => ' ', $the_brand . ' ' => '']);
1430
			}
1431
		}
1432
1433
		if (!empty($_SESSION['version_emulate']))
1434
		{
1435
			$context['forum_version'] = $the_brand . ' ' . $_SESSION['version_emulate'];
1436
			$the_version = $_SESSION['version_emulate'];
1437
		}
1438
1439
		if (isset($_SESSION['single_version_emulate']))
1440
		{
1441
			unset($_SESSION['single_version_emulate']);
1442
		}
1443
1444
		if (empty($instadds))
1445
		{
1446
			$instadds = loadInstalledPackages();
1447
			$installed_adds = [];
1448
1449
			// Look through the list of installed mods...
1450
			foreach ($instadds as $installed_add)
1451
			{
1452
				$installed_adds[$installed_add['package_id']] = [
1453
					'id' => $installed_add['id'],
1454
					'version' => $installed_add['version'],
1455
					'time_installed' => $installed_add['time_installed'],
1456
				];
1457
			}
1458
1459
			// Get a list of all the ids installed, so the latest packages won't include already installed ones.
1460
			$context['installed_adds'] = array_keys($installed_adds);
1461
		}
1462
1463
		if (empty($packages))
1464
		{
1465
			foreach ($context['package_types'] as $type)
1466
			{
1467
				$packages[$type] = [];
1468
			}
1469
		}
1470
1471
		try
1472
		{
1473
			$dir = new FilesystemIterator(BOARDDIR . '/packages', FilesystemIterator::SKIP_DOTS);
1474
			$filtered_dir = new PackagesFilterIterator($dir);
1475
1476
			$dirs = [];
1477
			$sort_id = [
1478
				'addon' => 1,
1479
				'avatar' => 1,
1480
				'language' => 1,
1481
				'smiley' => 1,
1482
				'unknown' => 1,
1483
			];
1484
			foreach ($filtered_dir as $package)
1485
			{
1486
				foreach ($context['package_types'] as $type)
1487
				{
1488
					if (isset($context['available_' . $type][md5($package->getFilename())]))
1489
					{
1490
						continue 2;
1491
					}
1492
				}
1493
1494
				// Skip directories or files that are named the same.
1495
				if ($package->isDir())
1496
				{
1497
					if (in_array($package, $dirs, true))
1498
					{
1499
						continue;
1500
					}
1501
1502
					$dirs[] = $package;
1503
				}
1504
				elseif (strtolower(substr($package->getFilename(), -7)) === '.tar.gz')
1505
				{
1506
					if (in_array(substr($package, 0, -7), $dirs, true))
1507
					{
1508
						continue;
1509
					}
1510
1511
					$dirs[] = substr($package, 0, -7);
1512
				}
1513
				elseif (strtolower($package->getExtension()) === 'zip' || strtolower($package->getExtension()) === 'tgz')
1514
				{
1515
					if (in_array(substr($package->getBasename(), 0, -4), $dirs, true))
1516
					{
1517
						continue;
1518
					}
1519
1520
					$dirs[] = substr($package->getBasename(), 0, -4);
1521
				}
1522
1523
				$packageInfo = getPackageInfo($package->getFilename());
1524
				if (!is_array($packageInfo) || empty($packageInfo))
1525
				{
1526
					continue;
1527
				}
1528
1529
				$packageInfo['installed_id'] = isset($installed_adds[$packageInfo['id']]) ? $installed_adds[$packageInfo['id']]['id'] : 0;
1530
				$packageInfo['sort_id'] = $sort_id[$packageInfo['type']] ?? $sort_id['unknown'];
1531
				$packageInfo['is_installed'] = isset($installed_adds[$packageInfo['id']]);
1532
				$packageInfo['is_current'] = $packageInfo['is_installed'] && isset($installed_adds[$packageInfo['id']]) && ($installed_adds[$packageInfo['id']]['version'] == $packageInfo['version']);
1533
				$packageInfo['is_newer'] = $packageInfo['is_installed'] && isset($installed_adds[$packageInfo['id']]) && ($installed_adds[$packageInfo['id']]['version'] > $packageInfo['version']);
1534
				$packageInfo['can_install'] = false;
1535
				$packageInfo['can_uninstall'] = false;
1536
				$packageInfo['can_upgrade'] = false;
1537
				$packageInfo['can_emulate_install'] = false;
1538
				$packageInfo['can_emulate_uninstall'] = false;
1539
				$packageInfo['time_installed'] = $installed_adds[$packageInfo['id']]['time_installed'] ?? 0;
1540
1541
				// This package is currently NOT installed.  Check if it can be.
1542
				if (!$packageInfo['is_installed'] && $packageInfo['xml']->exists('install'))
1543
				{
1544
					// Check if there's an installation for *THIS* version
1545
					$installs = $packageInfo['xml']->set('install');
1546
					$packageInfo['time_installed'] = 0;
1547
					foreach ($installs as $install)
1548
					{
1549
						if (!$install->exists('@for') || matchPackageVersion($the_version, $install->fetch('@for')))
1550
						{
1551
							// Okay, this one is good to go.
1552
							$packageInfo['can_install'] = true;
1553
							break;
1554
						}
1555
					}
1556
1557
					// no install found for our version, let's see if one exists for another
1558
					if ($packageInfo['can_install'] === false && $install->exists('@for') && empty($_SESSION['version_emulate']))
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $install does not seem to be defined for all execution paths leading up to this point.
Loading history...
1559
					{
1560
						$reset = true;
1561
1562
						// Get the highest installed version that is available from the package
1563
						foreach ($installs as $install)
1564
						{
1565
							$packageInfo['can_emulate_install'] = matchHighestPackageVersion($install->fetch('@for'), $the_version, $reset);
1566
							$reset = false;
1567
						}
1568
					}
1569
				}
1570
				// An already installed, but old, package.  Can we upgrade it?
1571
				elseif ($packageInfo['is_installed'] && !$packageInfo['is_current'] && $packageInfo['xml']->exists('upgrade'))
1572
				{
1573
					$upgrades = $packageInfo['xml']->set('upgrade');
1574
1575
					// First, go through and check against the current version of ElkArte.
1576
					foreach ($upgrades as $upgrade)
1577
					{
1578
						// Even if it is for this ElkArte, is it for the installed version of the mod?
1579
						if ($upgrade->exists('@for') && !matchPackageVersion($the_version, $upgrade->fetch('@for')))
1580
						{
1581
							continue;
1582
						}
1583
1584
						if ($upgrade->exists('@from') && !matchPackageVersion($installed_adds[$packageInfo['id']]['version'], $upgrade->fetch('@from')))
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $installed_adds does not seem to be defined for all execution paths leading up to this point.
Loading history...
1585
						{
1586
							continue;
1587
						}
1588
1589
						$packageInfo['can_upgrade'] = true;
1590
						break;
1591
					}
1592
				}
1593
				// Note that it has to be the current version to be uninstallable.  Shucks.
1594
				elseif ($packageInfo['is_installed'] && $packageInfo['is_current'] && $packageInfo['xml']->exists('uninstall'))
1595
				{
1596
					$uninstalls = $packageInfo['xml']->set('uninstall');
1597
1598
					// Can we find any uninstallation methods that work for this ElkArte version?
1599
					foreach ($uninstalls as $uninstall)
1600
					{
1601
						if (!$uninstall->exists('@for') || matchPackageVersion($the_version, $uninstall->fetch('@for')))
1602
						{
1603
							$packageInfo['can_uninstall'] = true;
1604
							break;
1605
						}
1606
					}
1607
1608
					// No uninstall found for this version, let's see if one exists for another
1609
					if ($packageInfo['can_uninstall'] === false && $uninstall->exists('@for') && empty($_SESSION['version_emulate']))
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $uninstall does not seem to be defined for all execution paths leading up to this point.
Loading history...
1610
					{
1611
						$reset = true;
1612
1613
						// Get the highest installed version that is available from the package
1614
						foreach ($uninstalls as $uninstall)
1615
						{
1616
							$packageInfo['can_emulate_uninstall'] = matchHighestPackageVersion($uninstall->fetch('@for'), $the_version, $reset);
1617
							$reset = false;
1618
						}
1619
					}
1620
				}
1621
1622
				unset($packageInfo['xml']);
1623
1624
				// Add-on / Modification
1625
				if ($packageInfo['type'] === 'addon' || $packageInfo['type'] === 'modification' || $packageInfo['type'] === 'mod')
1626
				{
1627
					$sort_id['addon']++;
1628
					$packages['addon'][strtolower($packageInfo[$sort]) . '_' . $sort_id['addon']] = md5($package->getFilename());
1629
					$context['available_addon'][md5($package->getFilename())] = $packageInfo;
1630
				}
1631
				// Avatar package.
1632
				elseif ($packageInfo['type'] === 'avatar')
1633
				{
1634
					$sort_id[$packageInfo['type']]++;
1635
					$packages['avatar'][strtolower($packageInfo[$sort]) . '_' . $sort_id['avatar']] = md5($package->getFilename());
1636
					$context['available_avatar'][md5($package->getFilename())] = $packageInfo;
1637
				}
1638
				// Smiley package.
1639
				elseif ($packageInfo['type'] === 'smiley')
1640
				{
1641
					$sort_id[$packageInfo['type']]++;
1642
					$packages['smiley'][strtolower($packageInfo[$sort]) . '_' . $sort_id['smiley']] = md5($package->getFilename());
1643
					$context['available_smiley'][md5($package->getFilename())] = $packageInfo;
1644
				}
1645
				// Language package.
1646
				elseif ($packageInfo['type'] === 'language')
1647
				{
1648
					$sort_id[$packageInfo['type']]++;
1649
					$packages['language'][strtolower($packageInfo[$sort]) . '_' . $sort_id['language']] = md5($package->getFilename());
1650
					$context['available_language'][md5($package->getFilename())] = $packageInfo;
1651
				}
1652
				// Other stuff.
1653
				else
1654
				{
1655
					$sort_id['unknown']++;
1656
					$packages['unknown'][strtolower($packageInfo[$sort]) . '_' . $sort_id['unknown']] = md5($package->getFilename());
1657
					$context['available_unknown'][md5($package->getFilename())] = $packageInfo;
1658
				}
1659
			}
1660
		}
1661
		catch (UnexpectedValueException)
1662
		{
1663
			// @todo for now do nothing...
1664
		}
1665
1666
		if (isset($this->_req->query->desc))
1667
		{
1668
			krsort($packages[$params]);
1669
		}
1670
		else
1671
		{
1672
			ksort($packages[$params]);
1673
		}
1674
1675
		return $packages[$params];
1676
	}
1677
1678
	/**
1679
	 * Removes database changes if specified conditions are met.
1680
	 *
1681
	 * @param array $package_installed The installed package that contains the database changes.
1682
	 * @param AbstractTable $table_installer The object responsible for modifying database tables.
1683
	 */
1684
	public function removeDatabaseChanges($package_installed, $table_installer): void
1685
	{
1686
		// If there are database changes - and they want them removed - let's do it last!
1687
		if (empty($package_installed['db_changes']))
1688
		{
1689
			return;
1690
		}
1691
		if (empty($this->_req->post->do_db_changes))
1692
		{
1693
			return;
1694
		}
1695
		foreach ($package_installed['db_changes'] as $change)
1696
		{
1697
			if ($change[0] === 'remove_table' && isset($change[1]))
1698
			{
1699
				$table_installer->drop_table($change[1]);
1700
			}
1701
			elseif ($change[0] === 'remove_column' && isset($change[2]))
1702
			{
1703
				$table_installer->remove_column($change[1], $change[2]);
1704
			}
1705
			elseif ($change[0] === 'remove_index' && isset($change[2]))
1706
			{
1707
				$table_installer->remove_index($change[1], $change[2]);
1708
			}
1709
		}
1710
	}
1711
1712
	/**
1713
	 * Determine database changes based on the given package information and installed package.
1714
	 *
1715
	 * @param array $packageInfo The package information that may contain uninstall database changes.
1716
	 * @param array $package_installed The installed package that contains database changes.
1717
	 *
1718
	 * @return void
1719
	 */
1720
	public function determineDatabaseChanges($packageInfo, $package_installed): void
1721
	{
1722
		global $context, $txt;
1723
1724
		$context['database_changes'] = [];
1725
		if (isset($packageInfo['uninstall']['database']))
1726
		{
1727
			$context['database_changes'][] = $txt['execute_database_changes'] . ' - ' . $packageInfo['uninstall']['database'];
1728
		}
1729
		elseif (!empty($package_installed['db_changes']))
1730
		{
1731
			foreach ($package_installed['db_changes'] as $change)
1732
			{
1733
				if (isset($change[2], $txt['package_db_' . $change[0]]))
1734
				{
1735
					$context['database_changes'][] = sprintf($txt['package_db_' . $change[0]], $change[1], $change[2]);
1736
				}
1737
				elseif (isset($txt['package_db_' . $change[0]]))
1738
				{
1739
					$context['database_changes'][] = sprintf($txt['package_db_' . $change[0]], $change[1]);
1740
				}
1741
				else
1742
				{
1743
					$context['database_changes'][] = $change[0] . '-' . $change[1] . (isset($change[2]) ? '-' . $change[2] : '');
1744
				}
1745
			}
1746
		}
1747
	}
1748
1749
	/**
1750
	 * Table sorting function used in usort
1751
	 *
1752
	 * @param string[] $a
1753
	 * @param string[] $b
1754
	 *
1755
	 * @return int
1756
	 */
1757
	private function _sort_table_first($a, $b): int
1758
	{
1759
		if ($a[0] === $b[0])
1760
		{
1761
			return 0;
1762
		}
1763
1764
		return $a[0] === 'remove_table' ? -1 : 1;
1765
	}
1766
}
1767