Packages::_installThemes()   A
last analyzed

Complexity

Conditions 5
Paths 2

Size

Total Lines 33
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 15
c 1
b 0
f 0
nc 2
nop 0
dl 0
loc 33
rs 9.4555
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 dev
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 related to addon packages, including FTP connections when necessary.
37
 *
38
 * @package Packages
39
 */
40
class Packages extends AbstractController
41
{
42
	/** @var array|boolean listing of files in a packages */
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 un-install 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 \ElkArte\Helper\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
		// Lets just do it!
141
		$action->dispatch($subAction);
142
	}
143
144
	/**
145
	 * Test install/uninstall a package.
146
	 */
147
	public function action_install()
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 uninstalls 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 which 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()
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()
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 its 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 install
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)
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, 'install');
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)
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 it's 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()
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 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
		// Are 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 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 create 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's 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()
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
			foreach ($this->_req->post->custom_theme as $tid)
793
			{
794
				if (in_array($tid, $known_themes, true))
795
				{
796
					$custom_themes[] = $tid;
797
				}
798
			}
799
		}
800
801
		return $custom_themes;
802
	}
803
804
	/**
805
	 * Install Themes
806
	 *
807
	 * This method installs the custom themes.
808
	 *
809
	 * @return array
810
	 */
811
	private function _installThemes()
812
	{
813
		global $context;
814
815
		$themes_installed = [1];
816
817
		$context['theme_copies'] = [
818
			'require-file' => [],
819
			'require-dir' => [],
820
		];
821
822
		if (!empty($this->_req->post->theme_changes))
823
		{
824
			foreach ($this->_req->post->theme_changes as $change)
825
			{
826
				if (empty($change))
827
				{
828
					continue;
829
				}
830
831
				$theme_data = json_decode(base64_decode($change), true);
832
833
				if (empty($theme_data['type']))
834
				{
835
					continue;
836
				}
837
838
				$themes_installed[] = (int) $theme_data['id'];
839
				$context['theme_copies'][$theme_data['type']][$theme_data['orig']][] = $theme_data['future'];
840
			}
841
		}
842
843
		return $themes_installed;
844
	}
845
846
	/**
847
	 * List the files in a package.
848
	 */
849
	public function action_list()
850
	{
851
		global $txt, $context;
852
853
		// No package?  Show him or her the door.
854
		$package = $this->_req->getQuery('package', 'trim', '');
855
		if (empty($package))
856
		{
857
			redirectexit('action=admin;area=packages');
858
		}
859
860
		$context['breadcrumbs'][] = [
861
			'url' => getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => 'list', 'package' => $package]),
862
			'name' => $txt['list_file']
863
		];
864
		$context['page_title'] .= ' - ' . $txt['list_file'];
865
		$context['sub_template'] = 'list';
866
867
		// The filename...
868
		$context['filename'] = $package;
869
870
		// Let the unpacker do the work.
871
		if (is_file(BOARDDIR . '/packages/' . $context['filename']))
872
		{
873
			$context['files'] = read_tgz_file(BOARDDIR . '/packages/' . $context['filename'], null);
874
		}
875
		elseif ($this->fileFunc->isDir(BOARDDIR . '/packages/' . $context['filename']))
876
		{
877
			$context['files'] = $this->fileFunc->listtree(BOARDDIR . '/packages/' . $context['filename']);
878
		}
879
	}
880
881
	/**
882
	 * Display one of the files in a package.
883
	 */
884
	public function action_examine()
885
	{
886
		global $txt, $context;
887
888
		// No package?  Show him or her the door.
889
		if (!isset($this->_req->query->package) || $this->_req->query->package == '')
890
		{
891
			redirectexit('action=admin;area=packages');
892
		}
893
894
		// No file?  Show him or her the door.
895
		if (!isset($this->_req->query->file) || $this->_req->query->file == '')
896
		{
897
			redirectexit('action=admin;area=packages');
898
		}
899
900
		$this->_req->query->package = preg_replace('~[.]+~', '.', strtr($this->_req->query->package, ['/' => '_', '\\' => '_']));
901
		$this->_req->query->file = preg_replace('~[.]+~', '.', $this->_req->query->file);
902
903
		if (isset($this->_req->query->raw))
904
		{
905
			if (is_file(BOARDDIR . '/packages/' . $this->_req->query->package))
906
			{
907
				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

907
				echo /** @scrutinizer ignore-type */ read_tgz_file(BOARDDIR . '/packages/' . $this->_req->query->package, $this->_req->query->file, true);
Loading history...
908
			}
909
			elseif ($this->fileFunc->isDir(BOARDDIR . '/packages/' . $this->_req->query->package))
910
			{
911
				echo file_get_contents(BOARDDIR . '/packages/' . $this->_req->query->package . '/' . $this->_req->query->file);
912
			}
913
914
			obExit(false);
915
		}
916
917
		$context['breadcrumbs'][count($context['breadcrumbs']) - 1] = [
918
			'url' => getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => 'list', 'package' => $this->_req->query->package]),
919
			'name' => $txt['package_examine_file']
920
		];
921
		$context['page_title'] .= ' - ' . $txt['package_examine_file'];
922
		$context['sub_template'] = 'examine';
923
924
		// The filename...
925
		$context['package'] = $this->_req->query->package;
926
		$context['filename'] = $this->_req->query->file;
927
928
		// Let the unpacker do the work.... but make sure we handle images properly.
929
		if (in_array(strtolower(strrchr($this->_req->query->file, '.')), ['.bmp', '.gif', '.jpeg', '.jpg', '.png']))
930
		{
931
			$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 . '" />';
932
		}
933
		elseif (is_file(BOARDDIR . '/packages/' . $this->_req->query->package))
934
		{
935
			$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

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

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