PackageServers::action_list()   A
last analyzed

Complexity

Conditions 3
Paths 4

Size

Total Lines 16
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 7
c 0
b 0
f 0
nc 4
nop 0
dl 0
loc 16
rs 10
1
<?php
2
3
/**
4
 * This file handles the package servers and packages download, in Package Servers
5
 * area of administration panel.
6
 *
7
 * @package   ElkArte Forum
8
 * @copyright ElkArte Forum contributors
9
 * @license   BSD http://opensource.org/licenses/BSD-3-Clause (see accompanying LICENSE.txt file)
10
 *
11
 * This file contains code covered by:
12
 * copyright: 2011 Simple Machines (http://www.simplemachines.org)
13
 *
14
 * @version 2.0 dev
15
 *
16
 */
17
18
namespace ElkArte\Packages;
19
20
use ElkArte\AbstractController;
21
use ElkArte\Action;
22
use ElkArte\Exceptions\Exception;
23
use ElkArte\Helper\FileFunctions;
24
use ElkArte\Helper\Util;
25
use ElkArte\Http\FtpConnection;
26
use ElkArte\Languages\Txt;
27
use FilesystemIterator;
28
use IteratorIterator;
29
use UnexpectedValueException;
30
31
/**
32
 * PackageServers controller handles browsing, adding and removing
33
 * package servers, and download of a package from them.
34
 *
35
 * @package Packages
36
 */
37
class PackageServers extends AbstractController
38
{
39
	/** @var FileFunctions */
40
	protected $fileFunc;
41
42
	/**
43
	 * Called before all other methods when coming from the dispatcher or
44
	 * action class.  Loads language and templates files such that they are available
45
	 * to the other methods.
46
	 */
47
	public function pre_dispatch()
48
	{
49
		// Use the Packages language file. (split servers?)
50
		Txt::load('Packages');
51
52
		// Use the PackageServers template.
53
		theme()->getTemplates()->load('PackageServers');
54
		loadCSSFile('admin.css');
55
56
		// Load our subs.
57
		require_once(SUBSDIR . '/Package.subs.php');
58
59
		// Going to come in handy!
60
		$this->fileFunc = FileFunctions::instance();
61
	}
62
63
	/**
64
	 * Main dispatcher for package servers. Checks permissions,
65
	 * load files, and forwards to the right method.
66
	 *
67
	 * - Accessed by action=admin;area=packageservers
68
	 *
69
	 * @event integrate_sa_package_servers
70
	 * @see AbstractController::action_index
71
	 */
72
	public function action_index()
73
	{
74
		global $txt, $context;
75
76
		// This is for admins only.
77
		isAllowedTo('admin_forum');
78
79
		$context['page_title'] = $txt['package_servers'];
80
81
		// Here is a list of all the potentially valid actions.
82
		$subActions = [
83
			'servers' => [$this, 'action_list'],
84
			'browse' => [$this, 'action_browse'],
85
			'download' => [$this, 'action_download'],
86
			'upload2' => [$this, 'action_upload2'],
87
		];
88
89
		// Set up action/subaction stuff.
90
		$action = new Action('package_servers');
91
92
		// Now let's decide where we are taking this... call integrate_sa_package_servers
93
		$subAction = $action->initialize($subActions, 'servers');
94
95
		// For the template
96
		$context['sub_action'] = $subAction;
97
98
		// Set up some tabs, used when the add packages button (servers) is selected to mimic that controller
99
		$context[$context['admin_menu_name']]['object']->prepareTabData([
100
			'title' => $txt['package_manager'],
101
			'description' => $txt['package_servers_desc'],
102
			'class' => 'i-package',
103
			'tabs' => [
104
				'browse' => [
105
					'url' => getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => 'browse']),
106
					'label' => $txt['browse_packages'],
107
				],
108
				'servers' => [
109
					'url' => getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => 'servers', 'desc']),
110
					'description' => $txt['upload_packages_desc'],
111
					'label' => $txt['add_packages'],
112
				],
113
				'options' => [
114
					'url' => getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => 'options', 'desc']),
115
					'label' => $txt['package_settings'],
116
				],
117
			],
118
		]);
119
120
		// Lets just do it!
121
		$action->dispatch($subAction);
122
	}
123
124
	/**
125
	 * Load the package servers into context.
126
	 *
127
	 * - Accessed by action=admin;area=packageservers;sa=servers
128
	 */
129
	public function action_list(): void
130
	{
131
		global $txt, $context;
132
133
		// Ensure we use the correct template, and page title.
134
		$context['sub_template'] = 'servers';
135
		$context['page_title'] .= ' - ' . $txt['download_packages'];
136
137
		// Load the addon server.
138
		[$context['server']['name'], $context['server']['id']] = $this->_package_server();
139
140
		// Check if we will be able to write new archives in /packages folder.
141
		$context['package_download_broken'] = !$this->fileFunc->isWritable(BOARDDIR . '/packages') || !$this->fileFunc->isWritable(BOARDDIR . '/packages/installed.list');
142
		if ($context['package_download_broken'])
143
		{
144
			$this->ftp_connect();
145
		}
146
	}
147
148
	/**
149
	 * This method attempts to chmod packages and installed.list
150
	 *
151
	 * - uses FTP if necessary.
152
	 * - It sets the $context['package_download_broken'] status for the template.
153
	 * - Used by package servers pages.
154
	 */
155
	public function ftp_connect(): void
156
	{
157
		global $context, $modSettings, $txt;
158
159
		// Try to chmod from PHP first
160
		$this->fileFunc->chmod(BOARDDIR . '/packages');
161
		$this->fileFunc->chmod(BOARDDIR . '/packages/installed.list');
162
163
		$unwritable = !$this->fileFunc->isWritable(BOARDDIR . '/packages') || !$this->fileFunc->isWritable(BOARDDIR . '/packages/installed.list');
164
		if (!$unwritable)
165
		{
166
			// Using PHP was successful, no need for FTP
167
			$context['package_download_broken'] = false;
168
			return;
169
		}
170
171
		// Let's initialize $context
172
		$context['package_ftp'] = [
173
			'server' => '',
174
			'port' => '',
175
			'username' => '',
176
			'path' => '',
177
			'error' => '',
178
		];
179
180
		// Are they connected to their FTP account already?
181
		if (isset($this->_req->post->ftp_username))
182
		{
183
			$ftp_server = $this->_req->getPost('ftp_server', 'trim');
184
			$ftp_port = $this->_req->getPost('ftp_port', 'intval', 21);
185
			$ftp_username = $this->_req->getPost('ftp_username', 'trim', '');
186
			$ftp_password = $this->_req->getPost('ftp_password', 'trim', '');
187
			$ftp_path = $this->_req->getPost('ftp_path', 'trim');
188
189
			$ftp = new FtpConnection($ftp_server, $ftp_port, $ftp_username, $ftp_password);
190
191
			// I know, I know... but a lot of people want to type /home/xyz/... which is wrong, but logical.
192
			if (($ftp->error === false) && !$ftp->chdir($ftp_path))
193
			{
194
				$ftp_error = $ftp->error;
195
				$ftp->chdir(preg_replace('~^/home[2]?/[^/]+~', '', $ftp_path));
196
			}
197
		}
198
199
		// No attempt yet, or we had an error last time
200
		if (!isset($ftp) || $ftp->error !== false)
201
		{
202
			// Maybe we didn't even try yet
203
			if (!isset($ftp))
204
			{
205
				$ftp = new FtpConnection(null);
206
			}
207
			// ...or we failed
208
			elseif ($ftp->error !== false && !isset($ftp_error))
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $ftp does not seem to be defined for all execution paths leading up to this point.
Loading history...
209
			{
210
				$response = $txt['package_ftp_' . $ftp->error] ?? $ftp->error;
211
				$ftp_error = empty($ftp->last_message) ? $response : $ftp->last_message;
212
			}
213
214
			// Grab a few, often wrong, items to fill in the form.
215
			[$username, $detect_path, $found_path] = $ftp->detect_path(BOARDDIR);
216
217
			if ($found_path || !isset($ftp_path))
218
			{
219
				$ftp_path = $detect_path;
220
			}
221
222
			if (empty($ftp_username))
223
			{
224
				$ftp_username = $modSettings['package_username'] ?? $username;
225
			}
226
227
			// Fill the boxes for a FTP connection with data from the previous attempt too, if any
228
			$context['package_ftp'] = [
229
				'server' => $ftp_server ?? ($modSettings['package_server'] ?? 'localhost'),
230
				'port' => $ftp_port ?? ($modSettings['package_port'] ?? '21'),
231
				'username' => $ftp_username ?? ($modSettings['package_username'] ?? ''),
232
				'path' => $ftp_path,
233
				'error' => empty($ftp_error) ? null : $ftp_error,
234
			];
235
236
			// Announce the template it's time to display the ftp connection box.
237
			$context['package_download_broken'] = true;
238
		}
239
		else
240
		{
241
			// FTP connection has succeeded
242
			$context['package_download_broken'] = false;
243
			$context['package_ftp']['connection'] = $txt['package_ftp_test_success'];
244
245
			// Try to chmod packages folder and our list file.
246
			$ftp->ftp_chmod('packages', [0755, 0775, 0777]);
247
			$ftp->ftp_chmod('packages/installed.list', [0664, 0666]);
248
			$ftp->close();
249
		}
250
	}
251
252
	/**
253
	 * Browse a server's list of packages.
254
	 *
255
	 * - Accessed by action=admin;area=packageservers;sa=browse
256
	 */
257
	public function action_browse(): void
258
	{
259
		global $txt, $context;
260
261
		// Want to browsing the packages from the addon server
262
		if (isset($this->_req->query->server))
263
		{
264
			[$name, $url] = $this->_package_server();
265
		}
266
267
		// Minimum required parameter did not exist so dump out.
268
		else
269
		{
270
			throw new Exception('couldnt_connect', false);
271
		}
272
273
		// Might take some time.
274
		detectServer()->setTimeLimit(60);
275
276
		// Fetch the package listing from the server and json decode
277
		$packageListing = json_decode(fetch_web_data($url));
0 ignored issues
show
Bug introduced by
It seems like fetch_web_data($url) can also be of type false; however, parameter $json of json_decode() 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

277
		$packageListing = json_decode(/** @scrutinizer ignore-type */ fetch_web_data($url));
Loading history...
278
279
		// List out the packages...
280
		$context['package_list'] = [];
281
282
		// Pick the correct template.
283
		$context['sub_template'] = 'package_list';
284
		$context['page_title'] = $txt['package_servers'] . ($name !== '' ? ' - ' . $name : '');
285
		$context['package_server'] = $name;
286
287
		// If we received data
288
		$this->ifWeReceivedData($packageListing, $name, $txt['mod_section_count']);
289
290
		// Good time to sort the categories, the packages inside each category will be by last modification date.
291
		asort($context['package_list']);
292
	}
293
294
	/**
295
	 * Returns the contact details for the ElkArte package server
296
	 *
297
	 * - This is no longer necessary, but a leftover from when you could add insecure servers
298
	 * now it just returns what is saved in modSettings 'elkarte_addon_server'
299
	 *
300
	 * @return array
301
	 */
302
	private function _package_server(): array
303
	{
304
		$modSettings['elkarte_addon_server'] = $modSettings['elkarte_addon_server'] ?? 'https://elkarte.github.io/addons/package.json';
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $modSettings seems to never exist and therefore isset should always be false.
Loading history...
Comprehensibility Best Practice introduced by
$modSettings was never initialized. Although not strictly required by PHP, it is generally a good practice to add $modSettings = array(); before regardless.
Loading history...
305
306
		// Initialize the required variables.
307
		$name = 'ElkArte';
308
		$url = $modSettings['elkarte_addon_server'];
309
310
		return [$name, $url];
311
	}
312
313
	/**
314
	 * Returns a package array filled with the json information
315
	 *
316
	 * - Uses the parsed json file from the selected package server
317
	 *
318
	 * @param object $thisPackage
319
	 * @param string $packageSection
320
	 *
321
	 * @return array
322
	 */
323
	private function _load_package_json($thisPackage, $packageSection): array
324
	{
325
		// Populate the package info from the fetched data
326
		return [
327
			'id' => empty($thisPackage->pkid) ? $this->_assume_id($thisPackage) : [$thisPackage->pkid],
328
			'type' => $packageSection,
329
			'name' => Util::htmlspecialchars($thisPackage->title),
330
			'date' => htmlTime(strtotime($thisPackage->date)),
331
			'author' => Util::htmlspecialchars($thisPackage->author),
332
			'description' => empty($thisPackage->short) ? '' : Util::htmlspecialchars($thisPackage->short),
333
			'version' => $thisPackage->version,
334
			'elkversion' => $thisPackage->elkversion,
335
			'license' => $thisPackage->license,
336
			'hooks' => $thisPackage->allhooks,
337
			'server' => [
338
				'download' => (strpos($thisPackage->server[0]->download, 'http://') === 0 || strpos($thisPackage->server[0]->download, 'https://') === 0) && filter_var($thisPackage->server[0]->download, FILTER_VALIDATE_URL)
339
					? $thisPackage->server[0]->download : '',
340
				'support' => (strpos($thisPackage->server[0]->support, 'http://') === 0 || strpos($thisPackage->server[0]->support, 'https://') === 0) && filter_var($thisPackage->server[0]->support, FILTER_VALIDATE_URL)
341
					? $thisPackage->server[0]->support : '',
342
				'bugs' => (strpos($thisPackage->server[0]->bugs, 'http://') === 0 || strpos($thisPackage->server[0]->bugs, 'https://') === 0) && filter_var($thisPackage->server[0]->bugs, FILTER_VALIDATE_URL)
343
					? $thisPackage->server[0]->bugs : '',
344
				'link' => (strpos($thisPackage->server[0]->url, 'http://') === 0 || strpos($thisPackage->server[0]->url, 'https://') === 0) && filter_var($thisPackage->server[0]->url, FILTER_VALIDATE_URL)
345
					? $thisPackage->server[0]->url : '',
346
			],
347
		];
348
	}
349
350
	/**
351
	 * If no ID is provided for a package, create the most
352
	 * common ones based on author:package patterns
353
	 *
354
	 * - Should not be relied on
355
	 *
356
	 * @param object $thisPackage
357
	 *
358
	 * @return string[]
359
	 */
360
	private function _assume_id($thisPackage): array
361
	{
362
		$under = str_replace(' ', '_', $thisPackage->title);
363
		$none = str_replace(' ', '', $thisPackage->title);
364
365
		return [
366
			$thisPackage->author . ':' . $under,
367
			$thisPackage->author . ':' . $none,
368
			strtolower($thisPackage->author) . ':' . $under,
369
			strtolower($thisPackage->author) . ':' . $none,
370
			ucfirst($thisPackage->author) . ':' . $under,
371
			ucfirst($thisPackage->author) . ':' . $none,
372
			strtolower($thisPackage->author . ':' . $under),
373
			strtolower($thisPackage->author . ':' . $none),
374
		];
375
	}
376
377
	/**
378
	 * Determine the package file name so we can see if its been downloaded
379
	 *
380
	 * - Determines a unique package name given a master.xyz file
381
	 * - Create the name based on the repo name
382
	 * - removes invalid filename characters
383
	 *
384
	 * @param string $name
385
	 *
386
	 * @return string
387
	 */
388
	private function _rename_master($name): string
389
	{
390
		// Is this a "master" package from github or bitbucket?
391
		if (preg_match('~^http(s)?://(www.)?(bitbucket\.org|github\.com)/(.+?(master(\.zip|\.tar\.gz)))$~', $name, $matches) === 1)
392
		{
393
			// Name this master.zip based on repo name in the link
394
			$path_parts = pathinfo($matches[4]);
395
			[, $newname,] = explode('/', $path_parts['dirname']);
396
397
			// Just to be safe, no invalid file characters
398
			$invalid = array_merge(array_map('chr', range(0, 31)), ['<', '>', ':', '"', '/', '\\', '|', '?', '*']);
399
400
			// We could read the package info and see if we have a duplicate id & version, however that is
401
			// not always accurate, especially when dealing with repos.  So for now just put in no conflict mode
402
			// and do the save.
403
			if ($this->_req->getQuery('area') === 'packageservers' && $this->_req->getQuery('sa') === 'download')
404
			{
405
				$this->_req->query->auto = true;
406
			}
407
408
			return str_replace($invalid, '_', $newname) . $matches[6];
409
		}
410
411
		return basename($name);
412
	}
413
414
	/**
415
	 * Case-insensitive natural sort for packages
416
	 *
417
	 * @param array $a
418
	 * @param array $b
419
	 *
420
	 * @return int
421
	 */
422
	public function package_sort($a, $b): int
423
	{
424
		return strcasecmp($a['name'], $b['name']);
425
	}
426
427
	/**
428
	 * Download a package.
429
	 *
430
	 * What it does:
431
	 *
432
	 * - Accessed by action=admin;area=packageservers;sa=download
433
	 * - If server is set, loads json file from package server
434
	 *     - requires both section and num values to validate the file to download from the json file
435
	 * - If $_POST['byurl'] $_POST['filename'])) are set, will download a file from the url and save it as filename
436
	 * - If just $_POST['byurl'] is set will fetch that file and save it
437
	 *     - github and bitbucket master files are renamed to repo name to avoid collisions
438
	 * - Files are saved to the package directory and validate to be ElkArte packages
439
	 */
440
	public function action_download(): void
441
	{
442
		global $txt, $context;
443
444
		// Use the downloaded sub template.
445
		$context['sub_template'] = 'downloaded';
446
447
		// Security is good...
448
		checkSession(isset($this->_req->query->server) ? 'get' : '');
449
450
		// To download something, we need either a valid server or url.
451
		if (empty($this->_req->query->server)
452
			&& (!empty($this->_req->query->get) && !empty($this->_req->post->package)))
453
		{
454
			throw new Exception('package_get_error_is_zero', false);
455
		}
456
457
		// Start off with nothing
458
		$url = '';
459
		$name = '';
460
461
		// Download from a package server?
462
		if (isset($this->_req->query->server))
463
		{
464
			[$name, $url] = $this->_package_server();
465
466
			// Fetch the package listing from the package server
467
			$listing = json_decode(fetch_web_data($url));
468
469
			// Find the requested package by section and number, make sure it matches
470
			$section = $this->_req->query->section;
471
			$section = $listing->{$section};
472
473
			// This is what they requested, yes?
474
			if (basename($section[$this->_req->query->num]->server[0]->download) === $this->_req->query->package)
475
			{
476
				// Where to download it from
477
				$package_id = $this->_req->query->package;
478
				$package_name = $this->_rename_master($section[$this->_req->query->num]->server[0]->download);
479
				$path_url = pathinfo($section[$this->_req->query->num]->server[0]->download);
480
				$url = isset($path_url['dirname']) ? $path_url['dirname'] . '/' : '';
481
482
				// No extension ... set a default or nothing will show up in the listing
483
				if (strrpos(substr($name, 0, -3), '.') === false)
484
				{
485
					$needs_extension = true;
486
				}
487
			}
488
			// Not found or some monkey business
489
			else
490
			{
491
				throw new Exception('package_cant_download', false);
492
			}
493
		}
494
		// Entered a url and optional filename
495
		elseif (isset($this->_req->post->byurl) && !empty($this->_req->post->filename))
496
		{
497
			$package_id = $this->_req->post->package;
498
			$package_name = basename($this->_req->post->filename);
499
		}
500
		// Must just be a link then
501
		else
502
		{
503
			$package_id = $this->_req->post->package;
504
			$package_name = $this->_rename_master($this->_req->post->package);
505
		}
506
507
		// First make sure it's a package.
508
		$packageInfo = getPackageInfo($url . $package_id);
509
		if (!is_array($packageInfo))
510
		{
511
			throw new Exception($packageInfo);
512
		}
513
514
		if (!empty($needs_extension) && isset($packageInfo['name']))
515
		{
516
			$package_name = $this->_rename_master($packageInfo['name']) . '.zip';
517
		}
518
519
		// Avoid over writing any existing package files of the same name
520
		if (isset($this->_req->query->conflict) || (isset($this->_req->query->auto) && $this->fileFunc->fileExists(BOARDDIR . '/packages/' . $package_name)))
521
		{
522
			// Find the extension, change abc.tar.gz to abc_1.tar.gz...
523
			$ext = '';
524
			if (strrpos(substr($package_name, 0, -3), '.') !== false)
525
			{
526
				$ext = substr($package_name, strrpos(substr($package_name, 0, -3), '.'));
527
				$package_name = substr($package_name, 0, strrpos(substr($package_name, 0, -3), '.')) . '_';
528
			}
529
530
			// Find the first available free name
531
			$i = 1;
532
			while ($this->fileFunc->fileExists(BOARDDIR . '/packages/' . $package_name . $i . $ext))
533
			{
534
				$i++;
535
			}
536
537
			$package_name .= $i . $ext;
538
		}
539
540
		// Save the package to disk, use FTP if necessary
541
		$create_chmod_control = new PackageChmod();
542
		$create_chmod_control->createChmodControl(
543
			[BOARDDIR . '/packages/' . $package_name],
544
			[
545
				'destination_url' => getUrl('admin', ['action' => 'admin', 'area' => 'packageservers', 'sa' => 'download', 'package' => $package_id, '{session_data}']
546
					+ (isset($this->_req->query->server) ? ['server' => $this->_req->query->server] : [])
547
					+ (isset($this->_req->query->auto) ? ['auto' => ''] : [])
548
					+ (isset($this->_req->query->conflict) ? ['conflict' => ''] : [])),
549
				'crash_on_error' => true
550
			]
551
		);
552
553
		package_put_contents(BOARDDIR . '/packages/' . $package_name, fetch_web_data($url . $package_id));
554
555
		// You just downloaded an addon from SERVER_NAME_GOES_HERE.
556
		$context['package_server'] = $name;
557
558
		// Read in the newly saved package information
559
		$context['package'] = getPackageInfo($package_name);
560
561
		if (!is_array($context['package']))
562
		{
563
			throw new Exception('package_cant_download', false);
564
		}
565
566
		$context['package']['install']['link'] = '';
567
		if ($context['package']['type'] === 'modification' || $context['package']['type'] === 'addon')
568
		{
569
			$context['package']['install']['link'] = $this->getInstallLink('install_mod', $context['package']['filename']);
570
		}
571
		elseif ($context['package']['type'] === 'avatar')
572
		{
573
			$context['package']['install']['link'] = $this->getInstallLink('use_avatars', $context['package']['filename']);
574
		}
575
		elseif ($context['package']['type'] === 'language')
576
		{
577
			$context['package']['install']['link'] = $this->getInstallLink('add_languages', $context['package']['filename']);
578
		}
579
580
		$context['package']['list_files']['link'] = $this->getInstallLink('list_files', $context['package']['filename'], 'list');
581
582
		// Free a little bit of memory...
583
		unset($context['package']['xml']);
584
585
		$context['page_title'] = $txt['download_success'];
586
	}
587
588
	/**
589
	 * Upload a new package to the package directory.
590
	 *
591
	 * - Accessed by action=admin;area=packageservers;sa=upload2
592
	 */
593
	public function action_upload2(): void
594
	{
595
		global $txt, $context;
596
597
		// Setup the correct template, even though I'll admit we ain't downloading ;)
598
		$context['sub_template'] = 'downloaded';
599
600
		// @todo Use FTP if the packages directory is not writable.
601
		// Check the file was even sent!
602
		if (!isset($_FILES['package']['name']) || $_FILES['package']['name'] === '')
603
		{
604
			throw new Exception('package_upload_error_nofile');
605
		}
606
607
		if (!is_uploaded_file($_FILES['package']['tmp_name']) || (ini_get('open_basedir') === '' && !$this->fileFunc->fileExists($_FILES['package']['tmp_name'])))
608
		{
609
			throw new Exception('package_upload_error_failed');
610
		}
611
612
		// Make sure it has a sane filename.
613
		$_FILES['package']['name'] = preg_replace(['/\s/', '/\.[\.]+/', '/[^\w_\.\-]/'], ['_', '.', ''], $_FILES['package']['name']);
614
615
		if (strtolower(substr($_FILES['package']['name'], -4)) !== '.zip' && strtolower(substr($_FILES['package']['name'], -4)) !== '.tgz' && strtolower(substr($_FILES['package']['name'], -7)) !== '.tar.gz')
616
		{
617
			throw new Exception('package_upload_error_supports', false, ['zip, tgz, tar.gz']);
618
		}
619
620
		// We only need the filename...
621
		$packageName = basename($_FILES['package']['name']);
622
623
		// Setup the destination and throw an error if the file is already there!
624
		$destination = BOARDDIR . '/packages/' . $packageName;
625
626
		// @todo Maybe just roll it like we do for downloads?
627
		if ($this->fileFunc->fileExists($destination))
628
		{
629
			throw new Exception('package_upload_error_exists');
630
		}
631
632
		// Now move the file.
633
		move_uploaded_file($_FILES['package']['tmp_name'], $destination);
634
		$this->fileFunc->chmod($destination);
635
636
		// If we got this far that should mean it's available.
637
		$context['package'] = getPackageInfo($packageName);
638
		$context['package_server'] = '';
639
640
		// Not really a package, you lazy bum!
641
		if (!is_array($context['package']))
642
		{
643
			$this->fileFunc->delete($destination);
644
			Txt::load('Errors');
645
			$txt[$context['package']] = str_replace('{MANAGETHEMEURL}', getUrl('admin', ['action' => 'admin', 'area' => 'theme', 'sa' => 'admin', '{session_data}', 'hash' => '#theme_install']), $txt[$context['package']]);
646
			throw new Exception('package_upload_error_broken', false, $txt[$context['package']]);
647
		}
648
		try
649
		{
650
			$dir = new FilesystemIterator(BOARDDIR . '/packages', FilesystemIterator::SKIP_DOTS);
651
652
			$filter = new PackagesFilterIterator($dir);
653
			$packages = new IteratorIterator($filter);
654
655
			foreach ($packages as $package)
656
			{
657
				// No need to check these
658
				if ($package->getFilename() === $packageName)
659
				{
660
					continue;
661
				}
662
663
				// Read package info for the archive we found
664
				$packageInfo = getPackageInfo($package->getFilename());
665
				if (!is_array($packageInfo))
666
				{
667
					continue;
668
				}
669
670
				// If it was already uploaded under another name don't upload it again.
671
				if ($packageInfo['id'] === $context['package']['id'] && compareVersions($packageInfo['version'], $context['package']['version']) == 0)
672
				{
673
					$this->fileFunc->delete($destination);
674
					throw new Exception('Errors.package_upload_already_exists', 'general', $package->getFilename());
675
				}
676
			}
677
		}
678
		catch (UnexpectedValueException)
679
		{
680
			// @todo for now do nothing...
681
		}
682
683
		$context['package']['install']['link'] = '';
684
		if ($context['package']['type'] === 'modification' || $context['package']['type'] === 'addon')
685
		{
686
			$context['package']['install']['link'] = $this->getInstallLink('install_mod', $context['package']['filename']);
687
		}
688
		elseif ($context['package']['type'] === 'avatar')
689
		{
690
			$context['package']['install']['link'] = $this->getInstallLink('use_avatars', $context['package']['filename']);
691
		}
692
		elseif ($context['package']['type'] === 'language')
693
		{
694
			$context['package']['install']['link'] = $this->getInstallLink('add_languages', $context['package']['filename']);
695
		}
696
697
		$context['package']['list_files']['link'] = $this->getInstallLink('list_files', $context['package']['filename'], 'list');
698
699
		unset($context['package']['xml']);
700
701
		$context['page_title'] = $txt['package_uploaded_success'];
702
	}
703
704
	/**
705
	 * Generates an action link for a package.
706
	 *
707
	 * @param string $type The type of the package.
708
	 * @param string $filename The filename of the package.
709
	 * @param string $action (optional) The action to perform on the package. Default is 'install'.
710
	 *
711
	 * @return string Returns an HTML link for the package action.
712
	 */
713
	public function getInstallLink($type, $filename, $action = 'install'): string
714
	{
715
		global $txt;
716
717
		return '<a class="linkbutton" href="' . getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => $action, 'package' => $filename]) . '">' . $txt[$type] . '</a>';
718
	}
719
720
	/**
721
	 * Process received data to generate a package list
722
	 *
723
	 * @param mixed $packageListing The data containing the package list
724
	 * @param string $name The name of the package server
725
	 * @param int $mod_section_count The count of sections for the package list
726
	 *
727
	 * @return void
728
	 */
729
	public function ifWeReceivedData(mixed $packageListing, string $name, $mod_section_count): void
730
	{
731
		global $context;
732
733
		if (!empty($packageListing))
734
		{
735
			// Load the installed packages
736
			$installAdds = loadInstalledPackages();
737
738
			// Look through the list of installed mods and get version information for the compare
739
			$installed_adds = [];
740
			foreach ($installAdds as $installed_add)
741
			{
742
				$installed_adds[$installed_add['package_id']] = $installed_add['version'];
743
			}
744
745
			$the_version = strtr(FORUM_VERSION, ['ElkArte ' => '']);
746
			if (!empty($_SESSION['version_emulate']))
747
			{
748
				$the_version = $_SESSION['version_emulate'];
749
			}
750
751
			// Parse the json file, each section contains a category of addons
752
			$packageNum = 0;
753
			foreach ($packageListing as $packageSection => $section_items)
754
			{
755
				// Section title / header for the category
756
				$context['package_list'][$packageSection] = [
757
					'title' => Util::htmlspecialchars(ucwords($packageSection)),
758
					'text' => '',
759
					'items' => [],
760
				];
761
762
				// Load each package array as an item
763
				$section_count = 0;
764
				foreach ($section_items as $thisPackage)
765
				{
766
					// Read in the package info from the fetched data
767
					$package = $this->_load_package_json($thisPackage, $packageSection);
768
					$package['possible_ids'] = $package['id'];
769
770
					// Check the installation status
771
					$package['can_install'] = false;
772
					$is_installed = array_intersect(array_keys($installed_adds), $package['possible_ids']);
773
					$package['is_installed'] = !empty($is_installed);
774
775
					// Set the ID from our potential list should the ID not be provided in the package .yaml
776
					$package['id'] = $package['is_installed'] ? array_shift($is_installed) : $package['id'][0];
777
778
					// Version installed vs version available
779
					$package['is_current'] = !empty($package['is_installed']) && compareVersions($installed_adds[$package['id']], $package['version']) == 0;
780
					$package['is_newer'] = !empty($package['is_installed']) && compareVersions($package['version'], $installed_adds[$package['id']]) > 0;
781
782
					// Set the package filename for downloading and pre-existence checking
783
					$base_name = $this->_rename_master($package['server']['download']);
784
					$package['filename'] = basename($package['server']['download']);
785
786
					// This package is either not installed, or installed but old.
787
					if (!$package['is_installed'] || (!$package['is_current'] && !$package['is_newer']))
788
					{
789
						// Does it claim to install on this version of ElkArte?
790
						$path_parts = pathinfo($base_name);
791
						if (!empty($thisPackage->elkversion) && isset($path_parts['extension']) && in_array($path_parts['extension'], ['zip', 'tar', 'gz', 'tar.gz']))
792
						{
793
							// No install range given, then set one, it will all work out in the end.
794
							$for = strpos($thisPackage->elkversion, '-') === false ? $thisPackage->elkversion . '-' . $the_version : $thisPackage->elkversion;
795
							$package['can_install'] = matchPackageVersion($the_version, $for);
796
						}
797
					}
798
799
					// See if this filename already exists on the server
800
					$already_exists = getPackageInfo($base_name);
801
					$package['download_conflict'] = is_array($already_exists) && in_array($already_exists['id'], $package['possible_ids']) && compareVersions($already_exists['version'], $package['version']) != 0;
802
					$package['count'] = ++$packageNum;
803
804
					// Maybe they have downloaded it but not installed it
805
					$package['is_downloaded'] = !$package['is_installed'] && (is_array($already_exists) && in_array($already_exists['id'], $package['possible_ids']));
806
					if ($package['is_downloaded'])
807
					{
808
						// Is the available package newer than whats been downloaded?
809
						$package['is_newer'] = compareVersions($package['version'], $already_exists['version']) > 0;
810
					}
811
812
					// Build the download to server link
813
					$package['download']['href'] = getUrl('admin', ['action' => 'admin', 'area' => 'packageservers', 'sa' => 'download', 'server' => $name, 'section' => $packageSection, 'num' => $section_count, 'package' => $package['filename']] + ($package['download_conflict'] ? ['conflict'] : []) + ['{session_data}']);
814
					$package['download']['link'] = '<a href="' . $package['download']['href'] . '">' . $package['name'] . '</a>';
815
816
					// Add this package to the list
817
					$context['package_list'][$packageSection]['items'][$packageNum] = $package;
818
					$section_count++;
819
				}
820
821
				// Sort them naturally
822
				usort($context['package_list'][$packageSection]['items'], fn($a, $b) => $this->package_sort($a, $b));
823
824
				$context['package_list'][$packageSection]['text'] = sprintf($mod_section_count, $section_count);
825
			}
826
		}
827
	}
828
}
829