PackageServers::action_upload2()   F
last analyzed

Complexity

Conditions 25
Paths 261

Size

Total Lines 119
Code Lines 50

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 25
eloc 50
c 0
b 0
f 0
nc 261
nop 0
dl 0
loc 119
rs 2.5708

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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 Beta 1
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
		// Let's 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 the /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 an 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 the 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 browse the packages from the addon server
262
		if ($this->_req->hasQuery('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 the 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' => (str_starts_with($thisPackage->server[0]->download, 'http://') || str_starts_with($thisPackage->server[0]->download, 'https://')) && filter_var($thisPackage->server[0]->download, FILTER_VALIDATE_URL)
339
					? $thisPackage->server[0]->download : '',
340
				'support' => (str_starts_with($thisPackage->server[0]->support, 'http://') || str_starts_with($thisPackage->server[0]->support, 'https://')) && filter_var($thisPackage->server[0]->support, FILTER_VALIDATE_URL)
341
					? $thisPackage->server[0]->support : '',
342
				'bugs' => (str_starts_with($thisPackage->server[0]->bugs, 'http://') || str_starts_with($thisPackage->server[0]->bugs, 'https://')) && filter_var($thisPackage->server[0]->bugs, FILTER_VALIDATE_URL)
343
					? $thisPackage->server[0]->bugs : '',
344
				'link' => (str_starts_with($thisPackage->server[0]->url, 'http://') || str_starts_with($thisPackage->server[0]->url, 'https://')) && 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 it's 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 the 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 validated 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($this->_req->hasQuery('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 ($this->_req->hasQuery('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 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 overwriting any existing package files of the same name
520
		if ($this->_req->hasQuery('conflict') || ($this->_req->hasQuery('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
					+ ($this->_req->hasQuery('server') ? ['server' => $this->_req->query->server] : [])
547
					+ ($this->_req->hasQuery('auto') ? ['auto' => ''] : [])
548
					+ ($this->_req->hasQuery('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
		// Ensure nested structures are arrays before assigning nested offsets
567
		if (empty($context['package']['install']) || !is_array($context['package']['install']))
568
		{
569
			$context['package']['install'] = [];
570
		}
571
		if (empty($context['package']['list_files']) || !is_array($context['package']['list_files']))
572
		{
573
			$context['package']['list_files'] = [];
574
		}
575
576
		$context['package']['install']['link'] = '';
577
		if ($context['package']['type'] === 'modification' || $context['package']['type'] === 'addon')
578
		{
579
			$context['package']['install']['link'] = $this->getInstallLink('install_mod', $context['package']['filename']);
580
		}
581
		elseif ($context['package']['type'] === 'avatar')
582
		{
583
			$context['package']['install']['link'] = $this->getInstallLink('use_avatars', $context['package']['filename']);
584
		}
585
		elseif ($context['package']['type'] === 'language')
586
		{
587
			$context['package']['install']['link'] = $this->getInstallLink('add_languages', $context['package']['filename']);
588
		}
589
590
		$context['package']['list_files']['link'] = $this->getInstallLink('list_files', $context['package']['filename'], 'list');
591
592
		// Free a little bit of memory...
593
		unset($context['package']['xml']);
594
595
		$context['page_title'] = $txt['download_success'];
596
	}
597
598
	/**
599
	 * Upload a new package to the package directory.
600
	 *
601
	 * - Accessed by action=admin;area=packageservers;sa=upload2
602
	 */
603
	public function action_upload2(): void
604
	{
605
		global $txt, $context;
606
607
		// Set up the correct template, even though I'll admit we ain't downloading ;)
608
		$context['sub_template'] = 'downloaded';
609
610
		// @todo Use FTP if the packages directory is not writable.
611
		// Check the file was even sent!
612
		if (!isset($_FILES['package']['name']) || $_FILES['package']['name'] === '')
613
		{
614
			throw new Exception('package_upload_error_nofile');
615
		}
616
617
		if (!is_uploaded_file($_FILES['package']['tmp_name']) || (ini_get('open_basedir') === '' && !$this->fileFunc->fileExists($_FILES['package']['tmp_name'])))
618
		{
619
			throw new Exception('package_upload_error_failed');
620
		}
621
622
		// Make sure it has a sane filename.
623
		$_FILES['package']['name'] = preg_replace(['/\s/', '/\.[\.]+/', '/[^\w_\.\-]/'], ['_', '.', ''], $_FILES['package']['name']);
624
625
		if (strtolower(substr($_FILES['package']['name'], -4)) !== '.zip' && strtolower(substr($_FILES['package']['name'], -4)) !== '.tgz' && strtolower(substr($_FILES['package']['name'], -7)) !== '.tar.gz')
626
		{
627
			throw new Exception('package_upload_error_supports', false, ['zip, tgz, tar.gz']);
628
		}
629
630
		// We only need the filename...
631
		$packageName = basename($_FILES['package']['name']);
632
633
		// Set up the destination and throw an error if the file is already there!
634
		$destination = BOARDDIR . '/packages/' . $packageName;
635
636
		// @todo Maybe just roll it like we do for downloads?
637
		if ($this->fileFunc->fileExists($destination))
638
		{
639
			throw new Exception('package_upload_error_exists');
640
		}
641
642
		// Now move the file.
643
		move_uploaded_file($_FILES['package']['tmp_name'], $destination);
644
		$this->fileFunc->chmod($destination);
645
646
		// If we got this far, that should mean it's available.
647
		$context['package'] = getPackageInfo($packageName);
648
		$context['package_server'] = '';
649
650
		// Not really a package, you lazy bum!
651
		if (!is_array($context['package']))
652
		{
653
			$this->fileFunc->delete($destination);
654
			Txt::load('Errors');
655
			$txt[$context['package']] = str_replace('{MANAGETHEMEURL}', getUrl('admin', ['action' => 'admin', 'area' => 'theme', 'sa' => 'admin', '{session_data}', 'hash' => '#theme_install']), $txt[$context['package']]);
656
			throw new Exception('package_upload_error_broken', false, $txt[$context['package']]);
657
		}
658
		try
659
		{
660
			$dir = new FilesystemIterator(BOARDDIR . '/packages', FilesystemIterator::SKIP_DOTS);
661
662
			$filter = new PackagesFilterIterator($dir);
663
			$packages = new IteratorIterator($filter);
664
665
			foreach ($packages as $package)
666
			{
667
				// No need to check these
668
				if ($package->getFilename() === $packageName)
669
				{
670
					continue;
671
				}
672
673
				// Read package info for the archive we found
674
				$packageInfo = getPackageInfo($package->getFilename());
675
				if (!is_array($packageInfo))
676
				{
677
					continue;
678
				}
679
680
				// If it was already uploaded under another name, don't upload it again.
681
				if ($packageInfo['id'] === $context['package']['id'] && compareVersions($packageInfo['version'], $context['package']['version']) == 0)
682
				{
683
					$this->fileFunc->delete($destination);
684
					throw new Exception('Errors.package_upload_already_exists', 'general', $package->getFilename());
685
				}
686
			}
687
		}
688
		catch (UnexpectedValueException)
689
		{
690
			// @todo for now do nothing...
691
		}
692
693
		// Ensure nested structures are arrays before assigning nested offsets
694
		if (empty($context['package']['install']) || !is_array($context['package']['install']))
695
		{
696
			$context['package']['install'] = [];
697
		}
698
		if (empty($context['package']['list_files']) || !is_array($context['package']['list_files']))
699
		{
700
			$context['package']['list_files'] = [];
701
		}
702
703
		$context['package']['install']['link'] = '';
704
		if ($context['package']['type'] === 'modification' || $context['package']['type'] === 'addon')
705
		{
706
			$context['package']['install']['link'] = $this->getInstallLink('install_mod', $context['package']['filename']);
707
		}
708
		elseif ($context['package']['type'] === 'avatar')
709
		{
710
			$context['package']['install']['link'] = $this->getInstallLink('use_avatars', $context['package']['filename']);
711
		}
712
		elseif ($context['package']['type'] === 'language')
713
		{
714
			$context['package']['install']['link'] = $this->getInstallLink('add_languages', $context['package']['filename']);
715
		}
716
717
		$context['package']['list_files']['link'] = $this->getInstallLink('list_files', $context['package']['filename'], 'list');
718
719
		unset($context['package']['xml']);
720
721
		$context['page_title'] = $txt['package_uploaded_success'];
722
	}
723
724
	/**
725
	 * Generates an action link for a package.
726
	 *
727
	 * @param string $type The type of the package.
728
	 * @param string $filename The filename of the package.
729
	 * @param string $action (optional) The action to perform on the package. Default is 'install'.
730
	 *
731
	 * @return string Returns an HTML link for the package action.
732
	 */
733
	public function getInstallLink($type, $filename, $action = 'install'): string
734
	{
735
		global $txt;
736
737
		return '<a class="linkbutton" href="' . getUrl('admin', ['action' => 'admin', 'area' => 'packages', 'sa' => $action, 'package' => $filename]) . '">' . $txt[$type] . '</a>';
738
	}
739
740
	/**
741
	 * Process received data to generate a package list
742
	 *
743
	 * @param mixed $packageListing The data containing the package list
744
	 * @param string $name The name of the package server
745
	 * @param int $mod_section_count The count of sections for the package list
746
	 *
747
	 * @return void
748
	 */
749
	public function ifWeReceivedData(mixed $packageListing, string $name, $mod_section_count): void
750
	{
751
		global $context;
752
753
		if (!empty($packageListing))
754
		{
755
			// Load the installed packages
756
			$installAdds = loadInstalledPackages();
757
758
			// Look through the list of installed mods and get version information for the compare
759
			$installed_adds = [];
760
			foreach ($installAdds as $installed_add)
761
			{
762
				$installed_adds[$installed_add['package_id']] = $installed_add['version'];
763
			}
764
765
			$the_version = strtr(FORUM_VERSION, ['ElkArte ' => '']);
766
			if (!empty($_SESSION['version_emulate']))
767
			{
768
				$the_version = $_SESSION['version_emulate'];
769
			}
770
771
			// Parse the JSON file, each section contains a category of addons
772
			$packageNum = 0;
773
			foreach ($packageListing as $packageSection => $section_items)
774
			{
775
				// Section title / header for the category
776
				$context['package_list'][$packageSection] = [
777
					'title' => Util::htmlspecialchars(ucwords($packageSection)),
778
					'text' => '',
779
					'items' => [],
780
				];
781
782
				// Load each package array as an item
783
				$section_count = 0;
784
				foreach ($section_items as $thisPackage)
785
				{
786
					// Read in the package info from the fetched data
787
					$package = $this->_load_package_json($thisPackage, $packageSection);
788
					$package['possible_ids'] = $package['id'];
789
790
					// Check the installation status
791
					$package['can_install'] = false;
792
					$is_installed = array_intersect(array_keys($installed_adds), $package['possible_ids']);
793
					$package['is_installed'] = !empty($is_installed);
794
795
					// Set the ID from our potential list should the ID not be provided in the package .yaml
796
					$package['id'] = $package['is_installed'] ? array_shift($is_installed) : $package['id'][0];
797
798
					// Version installed vs version available
799
					$package['is_current'] = !empty($package['is_installed']) && compareVersions($installed_adds[$package['id']], $package['version']) == 0;
800
					$package['is_newer'] = !empty($package['is_installed']) && compareVersions($package['version'], $installed_adds[$package['id']]) > 0;
801
802
					// Set the package filename for downloading and pre-existence checking
803
					$base_name = $this->_rename_master($package['server']['download']);
804
					$package['filename'] = basename($package['server']['download']);
805
806
					// This package is either not installed or installed but old.
807
					if (!$package['is_installed'] || (!$package['is_current'] && !$package['is_newer']))
808
					{
809
						// Does it claim to install on this version of ElkArte?
810
						$path_parts = pathinfo($base_name);
811
						if (!empty($thisPackage->elkversion) && isset($path_parts['extension']) && in_array($path_parts['extension'], ['zip', 'tar', 'gz', 'tar.gz']))
812
						{
813
							// No installation range given, then set one, it will all work out in the end.
814
							$for = !str_contains($thisPackage->elkversion, '-') ? $thisPackage->elkversion . '-' . $the_version : $thisPackage->elkversion;
815
							$package['can_install'] = matchPackageVersion($the_version, $for);
816
						}
817
					}
818
819
					// See if this filename already exists on the server
820
					$already_exists = getPackageInfo($base_name);
821
					$package['download_conflict'] = is_array($already_exists) && in_array($already_exists['id'], $package['possible_ids']) && compareVersions($already_exists['version'], $package['version']) != 0;
822
					$package['count'] = ++$packageNum;
823
824
					// Maybe they have downloaded it but not installed it
825
					$package['is_downloaded'] = !$package['is_installed'] && (is_array($already_exists) && in_array($already_exists['id'], $package['possible_ids']));
826
					if ($package['is_downloaded'])
827
					{
828
						// Is the available package newer than what's been downloaded?
829
						$package['is_newer'] = compareVersions($package['version'], $already_exists['version']) > 0;
830
					}
831
832
					// Build the download to the server link
833
					$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}']);
834
					$package['download']['link'] = '<a href="' . $package['download']['href'] . '">' . $package['name'] . '</a>';
835
836
					// Add this package to the list
837
					$context['package_list'][$packageSection]['items'][$packageNum] = $package;
838
					$section_count++;
839
				}
840
841
				// Sort them naturally
842
				usort($context['package_list'][$packageSection]['items'], fn($a, $b) => $this->package_sort($a, $b));
843
844
				$context['package_list'][$packageSection]['text'] = sprintf($mod_section_count, $section_count);
845
			}
846
		}
847
	}
848
}
849