Passed
Push — master ( c0a3a7...3b84a4 )
by Jeroen
58:51
created

engine/classes/ElggPluginPackage.php (2 issues)

1
<?php
2
3
/**
4
 * Manages plugin packages under mod.
5
 *
6
 * @todo       This should eventually be merged into \ElggPlugin.
7
 * Currently \ElggPlugin objects are only used to get and save
8
 * plugin settings and user settings, so not every plugin
9
 * has an \ElggPlugin object.  It's not implemented in \ElggPlugin
10
 * right now because of conflicts with at least the constructor,
11
 * enable(), disable(), and private settings.
12
 *
13
 * Around 1.9 or so we should each plugin over to using
14
 * \ElggPlugin and merge \ElggPluginPackage and \ElggPlugin.
15
 *
16
 * @package    Elgg.Core
17
 * @subpackage Plugins
18
 * @since      1.8
19
 */
20
class ElggPluginPackage {
21
22
	const STATIC_CONFIG_FILENAME = 'elgg-plugin.php';
23
24
	/**
25
	 * The required files in the package
26
	 *
27
	 * @var array
28
	 */
29
	private $requiredFiles = [
30
		'manifest.xml'
31
	];
32
33
	/**
34
	 * The optional files that can be read and served through the markdown page handler
35
	 * @var array
36
	 */
37
	private $textFiles = [
38
		'README.txt',
39
		'CHANGES.txt',
40
		'INSTALL.txt',
41
		'COPYRIGHT.txt',
42
		'LICENSE.txt',
43
		'README',
44
		'README.md',
45
		'README.markdown'
46
	];
47
48
	/**
49
	 * Valid types for provides.
50
	 *
51
	 * @var array
52
	 */
53
	private $providesSupportedTypes = [
54
		'plugin',
55
		'php_extension'
56
	];
57
58
	/**
59
	 * The type of requires/conflicts supported
60
	 *
61
	 * @var array
62
	 */
63
	private $depsSupportedTypes = [
64
		'elgg_release',
65
		'php_version',
66
		'php_extension',
67
		'php_ini',
68
		'plugin',
69
		'priority',
70
	];
71
72
	/**
73
	 * An invalid plugin error.
74
	 */
75
	private $errorMsg = '';
76
77
	/**
78
	 * The plugin's manifest object
79
	 *
80
	 * @var \ElggPluginManifest
81
	 */
82
	protected $manifest;
83
84
	/**
85
	 * The plugin's full path
86
	 *
87
	 * @var string
88
	 */
89
	protected $path;
90
91
	/**
92
	 * Is the plugin valid?
93
	 *
94
	 * @var mixed Bool after validation check, null before.
95
	 */
96
	protected $valid = null;
97
98
	/**
99
	 * The plugin ID (dir name)
100
	 *
101
	 * @var string
102
	 */
103
	protected $id;
104
105
	/**
106
	 * Load a plugin package from mod/$id or by full path.
107
	 *
108
	 * @param string $plugin   The ID (directory name) or full path of the plugin.
109
	 * @param bool   $validate Automatically run isValid()?
110
	 *
111
	 * @throws PluginException
112
	 */
113 243
	public function __construct($plugin, $validate = true) {
114 243
		$plugin_path = _elgg_config()->plugins_path;
115
		// @todo wanted to avoid another is_dir() call here.
116
		// should do some profiling to see how much it affects
117 243
		if (strpos($plugin, $plugin_path) === 0 || is_dir($plugin)) {
118
			// this is a path
119 243
			$path = \Elgg\Project\Paths::sanitize($plugin);
120
121
			// the id is the last element of the array
122 243
			$path_array = explode('/', trim($path, '/'));
123 243
			$id = array_pop($path_array);
124
		} else {
125
			// this is a plugin id
126
			// strict plugin names
127 1
			if (preg_match('/[^a-z0-9\.\-_]/i', $plugin)) {
128
				throw new \PluginException(_elgg_services()->translator->translate('PluginException:InvalidID', [$plugin]));
129
			}
130
131 1
			$path = "{$plugin_path}$plugin/";
132 1
			$id = $plugin;
133
		}
134
135 243
		if (!is_dir($path)) {
136 1
			throw new \PluginException(_elgg_services()->translator->translate('PluginException:InvalidPath', [$path]));
137
		}
138
139 242
		$this->path = $path;
140 242
		$this->id = $id;
141
142 242
		if ($validate && !$this->isValid()) {
143
			if ($this->errorMsg) {
144
				throw new \PluginException(_elgg_services()->translator->translate('PluginException:InvalidPlugin:Details',
145
					[$plugin, $this->errorMsg]));
146
			} else {
147
				throw new \PluginException(_elgg_services()->translator->translate('PluginException:InvalidPlugin', [$plugin]));
148
			}
149
		}
150 242
	}
151
152
	/********************************
153
	 * Validation and sanity checks *
154
	 ********************************/
155
156
	/**
157
	 * Checks if this is a valid Elgg plugin.
158
	 *
159
	 * Checks for requires files as defined at the start of this
160
	 * class.  Will check require manifest fields via \ElggPluginManifest
161
	 * for Elgg 1.8 plugins.
162
	 *
163
	 * @note This doesn't check dependencies or conflicts.
164
	 * Use {@link \ElggPluginPackage::canActivate()} or
165
	 * {@link \ElggPluginPackage::checkDependencies()} for that.
166
	 *
167
	 * @return bool
168
	 */
169 33
	public function isValid() {
170 33
		if (!isset($this->valid)) {
171 33
			$this->valid = $this->validate();
172
		}
173
174 33
		return $this->valid;
175
	}
176
177
	/**
178
	 * @return bool
179
	 */
180 33
	private function validate() {
181
		// check required files.
182 33
		$have_req_files = true;
183 33
		foreach ($this->requiredFiles as $file) {
184 33
			if (!is_readable($this->path . $file)) {
185
				$have_req_files = false;
186
				$this->errorMsg =
187
					_elgg_services()->translator->translate('ElggPluginPackage:InvalidPlugin:MissingFile', [$file]);
188
189 33
				return false;
190
			}
191
		}
192
193
		// check required files
194 33
		if (!$have_req_files) {
195
			return $this->valid = false;
196
		}
197
198
		// check for valid manifest.
199 33
		if (!$this->loadManifest()) {
200
			return false;
201
		}
202
203 33
		if (!$this->isNamedCorrectly()) {
204
			return false;
205
		}
206
207
		// can't require or conflict with yourself or something you provide.
208
		// make sure provides are all valid.
209 33
		if (!$this->hasSaneDependencies()) {
210
			return false;
211
		}
212
213 33
		if (!$this->hasReadableConfigFile()) {
214
			return false;
215
		}
216
217 33
		return true;
218
	}
219
220
	/**
221
	 * Check that, if the plugin has a static config file, it is readable. We wait to read the contents
222
	 * because we don't want to risk crashing the whole plugins page.
223
	 *
224
	 * @return bool
225
	 */
226 33
	private function hasReadableConfigFile() {
227 33
		$file = "{$this->path}/" . self::STATIC_CONFIG_FILENAME;
228 33
		if (!is_file($file)) {
229 30
			return true;
230
		}
231
232 5
		if (is_readable($file)) {
233 5
			return true;
234
		}
235
236
		$this->errorMsg =
237
			_elgg_services()->translator->translate('ElggPluginPackage:InvalidPlugin:UnreadableConfig');
238
239
		return false;
240
	}
241
242
	/**
243
	 * Check that the plugin is installed in the directory with name specified
244
	 * in the manifest's "id" element.
245
	 *
246
	 * @return bool
247
	 */
248 33
	private function isNamedCorrectly() {
249 33
		$manifest = $this->getManifest();
250 33
		if ($manifest) {
251 33
			$required_id = $manifest->getID();
252 33
			if (!empty($required_id) && ($required_id !== $this->id)) {
253
				$this->errorMsg =
254
					_elgg_services()->translator->translate('ElggPluginPackage:InvalidPlugin:InvalidId', [$required_id]);
255
256
				return false;
257
			}
258
		}
259
260 33
		return true;
261
	}
262
263
	/**
264
	 * Check the plugin doesn't require or conflict with itself
265
	 * or something provides.  Also check that it only list
266
	 * valid provides.  Deps are checked in checkDependencies()
267
	 *
268
	 * @note Plugins always provide themselves.
269
	 *
270
	 * @todo Don't let them require and conflict the same thing
271
	 *
272
	 * @return bool
273
	 */
274 33
	private function hasSaneDependencies() {
275
		// protection against plugins with no manifest file
276 33
		if (!$this->getManifest()) {
277
			return false;
278
		}
279
280
		// Note: $conflicts and $requires are not unused. They're called dynamically
281 33
		$conflicts = $this->getManifest()->getConflicts();
282 33
		$requires = $this->getManifest()->getRequires();
283 33
		$provides = $this->getManifest()->getProvides();
284
285 33
		foreach ($provides as $provide) {
286
			// only valid provide types
287 33
			if (!in_array($provide['type'], $this->providesSupportedTypes)) {
288
				$this->errorMsg =
289
					_elgg_services()->translator->translate('ElggPluginPackage:InvalidPlugin:InvalidProvides', [$provide['type']]);
290
291
				return false;
292
			}
293
294
			// doesn't conflict or require any of its provides
295 33
			$name = $provide['name'];
296 33
			foreach (['conflicts', 'requires'] as $dep_type) {
297 33
				foreach (${$dep_type} as $dep) {
298 33
					if (!in_array($dep['type'], $this->depsSupportedTypes)) {
299
						$this->errorMsg =
300
							_elgg_services()->translator->translate('ElggPluginPackage:InvalidPlugin:InvalidDependency', [$dep['type']]);
301
302
						return false;
303
					}
304
305
					// make sure nothing is providing something it conflicts or requires.
306 33
					if (isset($dep['name']) && $dep['name'] == $name) {
307 30
						$version_compare = version_compare($provide['version'], $dep['version'], $dep['comparison']);
308
309 30
						if ($version_compare) {
310
							$this->errorMsg =
311
								_elgg_services()->translator->translate('ElggPluginPackage:InvalidPlugin:CircularDep',
312
									[$dep['type'], $dep['name'], $this->id]);
313
314 33
							return false;
315
						}
316
					}
317
				}
318
			}
319
		}
320
321 33
		return true;
322
	}
323
324
325
	/************
326
	 * Manifest *
327
	 ************/
328
329
	/**
330
	 * Returns a parsed manifest file.
331
	 *
332
	 * @return \ElggPluginManifest
333
	 */
334 241
	public function getManifest() {
335 241
		if (!$this->manifest) {
336 210
			if (!$this->loadManifest()) {
337
				return false;
0 ignored issues
show
Bug Best Practice introduced by Brett Profitt
The expression return false returns the type false which is incompatible with the documented return type ElggPluginManifest.
Loading history...
338
			}
339
		}
340
341 241
		return $this->manifest;
342
	}
343
344
	/**
345
	 * Loads the manifest into this->manifest as an
346
	 * \ElggPluginManifest object.
347
	 *
348
	 * @return bool
349
	 */
350 241
	private function loadManifest() {
351 241
		$file = $this->path . 'manifest.xml';
352
353
		try {
354 241
			$this->manifest = new \ElggPluginManifest($file, $this->id);
355
		} catch (Exception $e) {
356
			$this->errorMsg = $e->getMessage();
357
358
			return false;
359
		}
360
361 241
		if ($this->manifest instanceof \ElggPluginManifest) {
362 241
			return true;
363
		}
364
365
		$this->errorMsg = _elgg_services()->translator->translate('unknown_error');
366
367
		return false;
368
	}
369
370
	/****************
371
	 * Readme Files *
372
	 ***************/
373
374
	/**
375
	 * Returns an array of present and readable text files
376
	 *
377
	 * @return array
378
	 */
379 1
	public function getTextFilenames() {
380 1
		return $this->textFiles;
381
	}
382
383
	/***********************
384
	 * Dependencies system *
385
	 ***********************/
386
387
	/**
388
	 * Returns if the Elgg system meets the plugin's dependency
389
	 * requirements.  This includes both requires and conflicts.
390
	 *
391
	 * Full reports can be requested.  The results are returned
392
	 * as an array of arrays in the form array(
393
	 *    'type' => requires|conflicts,
394
	 *    'dep' => array( dependency array ),
395
	 *    'status' => bool if depedency is met,
396
	 *    'comment' => optional comment to display to the user.
397
	 * )
398
	 *
399
	 * @param bool $full_report Return a full report.
400
	 *
401
	 * @return bool|array
402
	 */
403 3
	public function checkDependencies($full_report = false) {
404
		// Note: $conflicts and $requires are not unused. They're called dynamically
405 3
		$requires = $this->getManifest()->getRequires();
406 3
		$conflicts = $this->getManifest()->getConflicts();
407
408 3
		$enabled_plugins = elgg_get_plugins('active');
409 3
		$this_id = $this->getID();
410 3
		$report = [];
411
412
		// first, check if any active plugin conflicts with us.
413 3
		foreach ($enabled_plugins as $plugin) {
414 3
			$temp_conflicts = [];
415 3
			$temp_manifest = $plugin->getManifest();
416 3
			if ($temp_manifest instanceof \ElggPluginManifest) {
417 3
				$temp_conflicts = $plugin->getManifest()->getConflicts();
418
			}
419 3
			foreach ($temp_conflicts as $conflict) {
420 3
				if ($conflict['type'] == 'plugin' && $conflict['name'] == $this_id) {
421
					$result = $this->checkDepPlugin($conflict, $enabled_plugins, false);
422
423
					// rewrite the conflict to show the originating plugin
424
					$conflict['name'] = $plugin->getDisplayName();
425
426
					if (!$full_report && !$result['status']) {
427
						$css_id = preg_replace('/[^a-z0-9-]/i', '-', $plugin->getManifest()->getID());
428
						$link = elgg_view('output/url', [
429
							'text' => $plugin->getDisplayName(),
430
							'href' => "#$css_id",
431
						]);
432
433
						$key = 'ElggPluginPackage:InvalidPlugin:ConflictsWithPlugin';
434
						$this->errorMsg = _elgg_services()->translator->translate($key, [$link]);
435
436
						return $result['status'];
437
					} else {
438
						$report[] = [
439
							'type' => 'conflicted',
440
							'dep' => $conflict,
441
							'status' => $result['status'],
442 3
							'value' => $this->getManifest()->getVersion()
443
						];
444
					}
445
				}
446
			}
447
		}
448
449 3
		$check_types = ['requires', 'conflicts'];
450
451 3
		if ($full_report) {
452
			// Note: $suggests is not unused. It's called dynamically
453
			$suggests = $this->getManifest()->getSuggests();
454
			$check_types[] = 'suggests';
455
		}
456
457 3
		foreach ($check_types as $dep_type) {
458 3
			$inverse = ($dep_type == 'conflicts') ? true : false;
459
460 3
			foreach (${$dep_type} as $dep) {
461 3
				switch ($dep['type']) {
462
					case 'elgg_release':
463 3
						$result = $this->checkDepElgg($dep, elgg_get_version(true), $inverse);
464 3
						break;
465
466
					case 'plugin':
467
						$result = $this->checkDepPlugin($dep, $enabled_plugins, $inverse);
468
						break;
469
470
					case 'priority':
471
						$result = $this->checkDepPriority($dep, $enabled_plugins, $inverse);
472
						break;
473
474
					case 'php_version':
475
						$result = $this->checkDepPhpVersion($dep, $inverse);
476
						break;
477
478
					case 'php_extension':
479
						$result = $this->checkDepPhpExtension($dep, $inverse);
480
						break;
481
482
					case 'php_ini':
483
						$result = $this->checkDepPhpIni($dep, $inverse);
484
						break;
485
486
					default:
487
						$result = null;//skip further check
488
						break;
489
				}
490
491 3
				if ($result !== null) {
492
					// unless we're doing a full report, break as soon as we fail.
493 3
					if (!$full_report && !$result['status']) {
494
						$this->errorMsg = "Missing dependencies.";
495
496
						return $result['status'];
497
					} else {
498
						// build report element and comment
499 3
						$report[] = [
500 3
							'type' => $dep_type,
501 3
							'dep' => $dep,
502 3
							'status' => $result['status'],
503 3
							'value' => $result['value']
504
						];
505
					}
506
				}
507
			}
508
		}
509
510 3
		if ($full_report) {
511
			// add provides to full report
512
			$provides = $this->getManifest()->getProvides();
513
514
			foreach ($provides as $provide) {
515
				$report[] = [
516
					'type' => 'provides',
517
					'dep' => $provide,
518
					'status' => true,
519
					'value' => ''
520
				];
521
			}
522
523
			return $report;
524
		}
525
526 3
		return true;
527
	}
528
529
	/**
530
	 * Checks if $plugins meets the requirement by $dep.
531
	 *
532
	 * @param array $dep     An Elgg manifest.xml deps array
533
	 * @param array $plugins A list of plugins as returned by elgg_get_plugins();
534
	 * @param bool  $inverse Inverse the results to use as a conflicts.
535
	 *
536
	 * @return bool
537
	 */
538
	private function checkDepPlugin(array $dep, array $plugins, $inverse = false) {
539
		$r = _elgg_services()->plugins->checkProvides('plugin', $dep['name'], $dep['version'], $dep['comparison']);
540
541
		if ($inverse) {
542
			$r['status'] = !$r['status'];
543
		}
544
545
		return $r;
0 ignored issues
show
Bug Best Practice introduced by Brett Profitt
The expression return $r returns the type array|array<mixed,mixed|boolean> which is incompatible with the documented return type boolean.
Loading history...
546
	}
547
548
	/**
549
	 * Checks if $plugins meets the requirement by $dep.
550
	 *
551
	 * @param array $dep     An Elgg manifest.xml deps array
552
	 * @param array $plugins A list of plugins as returned by elgg_get_plugins();
553
	 * @param bool  $inverse Inverse the results to use as a conflicts.
554
	 *
555
	 * @return bool
556
	 */
557
	private function checkDepPriority(array $dep, array $plugins, $inverse = false) {
558
		// grab the \ElggPlugin using this package.
559
		$plugin_package = elgg_get_plugin_from_id($this->getID());
560
		if (!$plugin_package) {
561
			return [
562
				'status' => true,
563
				'value' => 'uninstalled'
564
			];
565
		}
566
567
		$test_plugin = elgg_get_plugin_from_id($dep['plugin']);
568
569
		// If this isn't a plugin or the plugin isn't installed or active
570
		// priority doesn't matter. Use requires to check if a plugin is active.
571
		if (!$test_plugin || !$test_plugin->isActive()) {
572
			return [
573
				'status' => true,
574
				'value' => 'uninstalled'
575
			];
576
		}
577
578
		$plugin_priority = $plugin_package->getPriority();
579
		$test_plugin_priority = $test_plugin->getPriority();
580
581
		switch ($dep['priority']) {
582
			case 'before':
583
				$status = $plugin_priority < $test_plugin_priority;
584
				break;
585
586
			case 'after':
587
				$status = $plugin_priority > $test_plugin_priority;
588
				break;
589
590
			default;
591
				$status = false;
592
		}
593
594
		// get the current value
595
		if ($plugin_priority < $test_plugin_priority) {
596
			$value = 'before';
597
		} else {
598
			$value = 'after';
599
		}
600
601
		if ($inverse) {
602
			$status = !$status;
603
		}
604
605
		return [
606
			'status' => $status,
607
			'value' => $value
608
		];
609
	}
610
611
	/**
612
	 * Checks if $elgg_version meets the requirement by $dep.
613
	 *
614
	 * @param array $dep          An Elgg manifest.xml deps array
615
	 * @param array $elgg_version An Elgg version (either YYYYMMDDXX or X.Y.Z)
616
	 * @param bool  $inverse      Inverse the result to use as a conflicts.
617
	 *
618
	 * @return bool
619
	 */
620 3
	private function checkDepElgg(array $dep, $elgg_version, $inverse = false) {
621 3
		$status = version_compare($elgg_version, $dep['version'], $dep['comparison']);
622
623 3
		if ($inverse) {
624
			$status = !$status;
625
		}
626
627
		return [
628 3
			'status' => $status,
629 3
			'value' => $elgg_version
630
		];
631
	}
632
633
	/**
634
	 * Checks if $php_version meets the requirement by $dep.
635
	 *
636
	 * @param array $dep     An Elgg manifest.xml deps array
637
	 * @param bool  $inverse Inverse the result to use as a conflicts.
638
	 *
639
	 * @return bool
640
	 */
641
	private function checkDepPhpVersion(array $dep, $inverse = false) {
642
		$php_version = phpversion();
643
		$status = version_compare($php_version, $dep['version'], $dep['comparison']);
644
645
		if ($inverse) {
646
			$status = !$status;
647
		}
648
649
		return [
650
			'status' => $status,
651
			'value' => $php_version
652
		];
653
	}
654
655
	/**
656
	 * Checks if the PHP extension in $dep is loaded.
657
	 *
658
	 * @todo Can this be merged with the plugin checker?
659
	 *
660
	 * @param array $dep     An Elgg manifest.xml deps array
661
	 * @param bool  $inverse Inverse the result to use as a conflicts.
662
	 *
663
	 * @return array An array in the form array(
664
	 *    'status' => bool
665
	 *    'value' => string The version provided
666
	 * )
667
	 */
668
	private function checkDepPhpExtension(array $dep, $inverse = false) {
669
		$name = $dep['name'];
670
		$version = $dep['version'];
671
		$comparison = $dep['comparison'];
672
673
		// not enabled.
674
		$status = extension_loaded($name);
675
676
		// enabled. check version.
677
		$ext_version = phpversion($name);
678
679
		if ($status) {
680
			// some extensions (like gd) don't provide versions. neat.
681
			// don't check version info and return a lie.
682
			if ($ext_version && $version) {
683
				$status = version_compare($ext_version, $version, $comparison);
684
			}
685
686
			if (!$ext_version) {
687
				$ext_version = '???';
688
			}
689
		}
690
691
		// some php extensions can be emulated, so check provides.
692
		if ($status == false) {
693
			$provides = _elgg_services()->plugins->checkProvides('php_extension', $name, $version, $comparison);
694
			$status = $provides['status'];
695
			$ext_version = $provides['value'];
696
		}
697
698
		if ($inverse) {
699
			$status = !$status;
700
		}
701
702
		return [
703
			'status' => $status,
704
			'value' => $ext_version
705
		];
706
	}
707
708
	/**
709
	 * Check if the PHP ini setting satisfies $dep.
710
	 *
711
	 * @param array $dep     An Elgg manifest.xml deps array
712
	 * @param bool  $inverse Inverse the result to use as a conflicts.
713
	 *
714
	 * @return bool
715
	 */
716
	private function checkDepPhpIni($dep, $inverse = false) {
717
		$name = $dep['name'];
718
		$value = $dep['value'];
719
		$comparison = $dep['comparison'];
720
721
		// ini_get() normalizes truthy values to 1 but falsey values to 0 or ''.
722
		// version_compare() considers '' < 0, so normalize '' to 0.
723
		// \ElggPluginManifest normalizes all bool values and '' to 1 or 0.
724
		$setting = ini_get($name);
725
726
		if ($setting === '') {
727
			$setting = 0;
728
		}
729
730
		$status = version_compare($setting, $value, $comparison);
731
732
		if ($inverse) {
733
			$status = !$status;
734
		}
735
736
		return [
737
			'status' => $status,
738
			'value' => $setting
739
		];
740
	}
741
742
	/**
743
	 * Returns the Plugin ID
744
	 *
745
	 * @return string
746
	 */
747 5
	public function getID() {
748 5
		return $this->id;
749
	}
750
751
	/**
752
	 * Returns the last error message.
753
	 *
754
	 * @return string
755
	 */
756
	public function getError() {
757
		return $this->errorMsg;
758
	}
759
}
760