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