1<?php 2/** 3 * Matomo - free/libre analytics platform 4 * 5 * @link https://matomo.org 6 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later 7 * 8 */ 9namespace Piwik; 10 11use Piwik\Archive\ArchiveInvalidator; 12use Piwik\Container\StaticContainer; 13use Piwik\Plugin\Dependency; 14use Piwik\Plugin\Manager; 15use Piwik\Plugin\MetadataLoader; 16 17if (!class_exists('Piwik\Plugin')) { 18 19/** 20 * Base class of all Plugin Descriptor classes. 21 * 22 * Any plugin that wants to add event observers to one of Piwik's {@hook # hooks}, 23 * or has special installation/uninstallation logic must implement this class. 24 * Plugins that can specify everything they need to in the _plugin.json_ files, 25 * such as themes, don't need to implement this class. 26 * 27 * Class implementations should be named after the plugin they are a part of 28 * (eg, `class UserCountry extends Plugin`). 29 * 30 * ### Plugin Metadata 31 * 32 * In addition to providing a place for plugins to install/uninstall themselves 33 * and add event observers, this class is also responsible for loading metadata 34 * found in the plugin.json file. 35 * 36 * The plugin.json file must exist in the root directory of a plugin. It can 37 * contain the following information: 38 * 39 * - **description**: An internationalized string description of what the plugin 40 * does. 41 * - **homepage**: The URL to the plugin's website. 42 * - **authors**: A list of author arrays with keys for 'name', 'email' and 'homepage' 43 * - **license**: The license the code uses (eg, GPL, MIT, etc.). 44 * - **version**: The plugin version (eg, 1.0.1). 45 * - **theme**: `true` or `false`. If `true`, the plugin will be treated as a theme. 46 * 47 * ### Examples 48 * 49 * **How to extend** 50 * 51 * use Piwik\Common; 52 * use Piwik\Plugin; 53 * use Piwik\Db; 54 * 55 * class MyPlugin extends Plugin 56 * { 57 * public function registerEvents() 58 * { 59 * return array( 60 * 'API.getReportMetadata' => 'getReportMetadata', 61 * 'Another.event' => array( 62 * 'function' => 'myOtherPluginFunction', 63 * 'after' => true // executes this callback after others 64 * ) 65 * ); 66 * } 67 * 68 * public function install() 69 * { 70 * Db::exec("CREATE TABLE " . Common::prefixTable('mytable') . "..."); 71 * } 72 * 73 * public function uninstall() 74 * { 75 * Db::exec("DROP TABLE IF EXISTS " . Common::prefixTable('mytable')); 76 * } 77 * 78 * public function getReportMetadata(&$metadata) 79 * { 80 * // ... 81 * } 82 * 83 * public function myOtherPluginFunction() 84 * { 85 * // ... 86 * } 87 * } 88 * 89 * @api 90 */ 91class Plugin 92{ 93 /** 94 * Name of this plugin. 95 * 96 * @var string 97 */ 98 protected $pluginName; 99 100 /** 101 * Holds plugin metadata. 102 * 103 * @var array 104 */ 105 private $pluginInformation; 106 107 /** 108 * As the cache is used quite often we avoid having to create instances all the time. We reuse it which is not 109 * perfect but efficient. If the cache is used we need to make sure to call setId() before usage as there 110 * is maybe a different key set since last usage. 111 * 112 * @var \Matomo\Cache\Eager 113 */ 114 private $cache; 115 116 /** 117 * Constructor. 118 * 119 * @param string|bool $pluginName A plugin name to force. If not supplied, it is set 120 * to the last part of the class name. 121 * @throws \Exception If plugin metadata is defined in both the getInformation() method 122 * and the **plugin.json** file. 123 */ 124 public function __construct($pluginName = false) 125 { 126 if (empty($pluginName)) { 127 $pluginName = explode('\\', get_class($this)); 128 $pluginName = end($pluginName); 129 } 130 $this->pluginName = $pluginName; 131 132 $cacheId = 'Plugin' . $pluginName . 'Metadata'; 133 $cache = Cache::getEagerCache(); 134 135 if ($cache->contains($cacheId)) { 136 $this->pluginInformation = $cache->fetch($cacheId); 137 } else { 138 $this->reloadPluginInformation(); 139 140 $cache->save($cacheId, $this->pluginInformation); 141 } 142 } 143 144 public function reloadPluginInformation() 145 { 146 $metadataLoader = new MetadataLoader($this->pluginName); 147 $this->pluginInformation = $metadataLoader->load(); 148 149 if ($this->hasDefinedPluginInformationInPluginClass() && $metadataLoader->hasPluginJson()) { 150 throw new \Exception('Plugin ' . $this->pluginName . ' has defined the method getInformation() and as well as having a plugin.json file. Please delete the getInformation() method from the plugin class. Alternatively, you may delete the plugin directory from plugins/' . $this->pluginName); 151 } 152 } 153 154 private function createCacheIfNeeded() 155 { 156 if (is_null($this->cache)) { 157 $this->cache = Cache::getEagerCache(); 158 } 159 } 160 161 private function hasDefinedPluginInformationInPluginClass() 162 { 163 $myClassName = get_class(); 164 $pluginClassName = get_class($this); 165 166 if ($pluginClassName == $myClassName) { 167 // plugin has not defined its own class 168 return false; 169 } 170 171 $foo = new \ReflectionMethod(get_class($this), 'getInformation'); 172 $declaringClass = $foo->getDeclaringClass()->getName(); 173 174 return $declaringClass != $myClassName; 175 } 176 177 /** 178 * Returns plugin information, including: 179 * 180 * - 'description' => string // 1-2 sentence description of the plugin 181 * - 'author' => string // plugin author 182 * - 'author_homepage' => string // author homepage URL (or email "mailto:youremail@example.org") 183 * - 'homepage' => string // plugin homepage URL 184 * - 'license' => string // plugin license 185 * - 'version' => string // plugin version number; examples and 3rd party plugins must not use Version::VERSION; 3rd party plugins must increment the version number with each plugin release 186 * - 'theme' => bool // Whether this plugin is a theme (a theme is a plugin, but a plugin is not necessarily a theme) 187 * 188 * @return array 189 */ 190 public function getInformation() 191 { 192 return $this->pluginInformation; 193 } 194 195 final public function isPremiumFeature() 196 { 197 return !empty($this->pluginInformation['price']['base']); 198 } 199 200 /** 201 * Returns a list of events with associated event observers. 202 * 203 * Derived classes should use this method to associate callbacks with events. 204 * 205 * @return array eg, 206 * 207 * array( 208 * 'API.getReportMetadata' => 'myPluginFunction', 209 * 'Another.event' => array( 210 * 'function' => 'myOtherPluginFunction', 211 * 'after' => true // execute after callbacks w/o ordering 212 * ) 213 * 'Yet.Another.event' => array( 214 * 'function' => 'myOtherPluginFunction', 215 * 'before' => true // execute before callbacks w/o ordering 216 * ) 217 * ) 218 * @since 2.15.0 219 */ 220 public function registerEvents() 221 { 222 return array(); 223 } 224 225 /** 226 * This method is executed after a plugin is loaded and translations are registered. 227 * Useful for initialization code that uses translated strings. 228 */ 229 public function postLoad() 230 { 231 return; 232 } 233 234 /** 235 * Defines whether the whole plugin requires a working internet connection 236 * If set to true, the plugin will be automatically unloaded if `enable_internet_features` is 0, 237 * even if the plugin is activated 238 * 239 * @return bool 240 */ 241 public function requiresInternetConnection() 242 { 243 return false; 244 } 245 246 /** 247 * Installs the plugin. Derived classes should implement this class if the plugin 248 * needs to: 249 * 250 * - create tables 251 * - update existing tables 252 * - etc. 253 * 254 * @throws \Exception if installation of fails for some reason. 255 */ 256 public function install() 257 { 258 return; 259 } 260 261 /** 262 * Uninstalls the plugins. Derived classes should implement this method if the changes 263 * made in {@link install()} need to be undone during uninstallation. 264 * 265 * In most cases, if you have an {@link install()} method, you should provide 266 * an {@link uninstall()} method. 267 * 268 * @throws \Exception if uninstallation of fails for some reason. 269 */ 270 public function uninstall() 271 { 272 return; 273 } 274 275 /** 276 * Executed every time the plugin is enabled. 277 */ 278 public function activate() 279 { 280 return; 281 } 282 283 /** 284 * Executed every time the plugin is disabled. 285 */ 286 public function deactivate() 287 { 288 return; 289 } 290 291 /** 292 * Returns the plugin version number. 293 * 294 * @return string 295 */ 296 final public function getVersion() 297 { 298 $info = $this->getInformation(); 299 return $info['version']; 300 } 301 302 /** 303 * Returns `true` if this plugin is a theme, `false` if otherwise. 304 * 305 * @return bool 306 */ 307 public function isTheme() 308 { 309 $info = $this->getInformation(); 310 return !empty($info['theme']) && (bool)$info['theme']; 311 } 312 313 /** 314 * Returns the plugin's base class name without the namespace, 315 * e.g., `"UserCountry"` when the plugin class is `"Piwik\Plugins\UserCountry\UserCountry"`. 316 * 317 * @return string 318 */ 319 final public function getPluginName() 320 { 321 return $this->pluginName; 322 } 323 324 /** 325 * Tries to find a component such as a Menu or Tasks within this plugin. 326 * 327 * @param string $componentName The name of the component you want to look for. In case you request a 328 * component named 'Menu' it'll look for a file named 'Menu.php' within the 329 * root of the plugin folder that implements a class named 330 * Piwik\Plugin\$PluginName\Menu . If such a file exists but does not implement 331 * this class it'll silently ignored. 332 * @param string $expectedSubclass If not empty, a check will be performed whether a found file extends the 333 * given subclass. If the requested file exists but does not extend this class 334 * a warning will be shown to advice a developer to extend this certain class. 335 * 336 * @return string|null Null if the requested component does not exist or an instance of the found 337 * component. 338 */ 339 public function findComponent($componentName, $expectedSubclass) 340 { 341 $this->createCacheIfNeeded(); 342 343 $cacheId = 'Plugin' . $this->pluginName . $componentName . $expectedSubclass; 344 345 $pluginsDir = Manager::getPluginDirectory($this->pluginName); 346 347 $componentFile = sprintf('%s/%s.php', $pluginsDir, $componentName); 348 349 if ($this->cache->contains($cacheId)) { 350 $classname = $this->cache->fetch($cacheId); 351 352 if (empty($classname)) { 353 return null; // might by "false" in case has no menu, widget, ... 354 } 355 356 if (file_exists($componentFile)) { 357 include_once $componentFile; 358 } 359 } else { 360 $this->cache->save($cacheId, false); // prevent from trying to load over and over again for instance if there is no Menu for a plugin 361 362 if (!file_exists($componentFile)) { 363 return null; 364 } 365 366 require_once $componentFile; 367 368 $classname = sprintf('Piwik\\Plugins\\%s\\%s', $this->pluginName, $componentName); 369 370 if (!class_exists($classname)) { 371 return null; 372 } 373 374 if (!empty($expectedSubclass) && !is_subclass_of($classname, $expectedSubclass)) { 375 Log::warning(sprintf('Cannot use component %s for plugin %s, class %s does not extend %s', 376 $componentName, $this->pluginName, $classname, $expectedSubclass)); 377 return null; 378 } 379 380 $this->cache->save($cacheId, $classname); 381 } 382 383 return $classname; 384 } 385 386 public function findMultipleComponents($directoryWithinPlugin, $expectedSubclass) 387 { 388 $this->createCacheIfNeeded(); 389 390 $cacheId = 'Plugin' . $this->pluginName . $directoryWithinPlugin . $expectedSubclass; 391 392 if ($this->cache->contains($cacheId)) { 393 $components = $this->cache->fetch($cacheId); 394 395 if ($this->includeComponents($components)) { 396 return $components; 397 } else { 398 // problem including one cached file, refresh cache 399 } 400 } 401 402 $components = $this->doFindMultipleComponents($directoryWithinPlugin, $expectedSubclass); 403 404 $this->cache->save($cacheId, $components); 405 406 return $components; 407 } 408 409 /** 410 * Detect whether there are any missing dependencies. 411 * 412 * @param null $piwikVersion Defaults to the current Piwik version 413 * @return bool 414 */ 415 public function hasMissingDependencies($piwikVersion = null) 416 { 417 $requirements = $this->getMissingDependencies($piwikVersion); 418 419 return !empty($requirements); 420 } 421 422 public function getMissingDependencies($piwikVersion = null) 423 { 424 if (empty($this->pluginInformation['require'])) { 425 return array(); 426 } 427 428 $dependency = $this->makeDependency($piwikVersion); 429 return $dependency->getMissingDependencies($this->pluginInformation['require']); 430 } 431 432 /** 433 * Returns a string (translated) describing the missing requirements for this plugin and the given Piwik version 434 * 435 * @param string $piwikVersion 436 * @return string "AnonymousPiwikUsageMeasurement requires PIWIK >=3.0.0" 437 */ 438 public function getMissingDependenciesAsString($piwikVersion = null) 439 { 440 if ($this->requiresInternetConnection() && !SettingsPiwik::isInternetEnabled()) { 441 return Piwik::translate('CorePluginsAdmin_PluginRequiresInternet'); 442 } 443 444 if (empty($this->pluginInformation['require'])) { 445 return ''; 446 } 447 $dependency = $this->makeDependency($piwikVersion); 448 449 $missingDependencies = $dependency->getMissingDependencies($this->pluginInformation['require']); 450 451 if(empty($missingDependencies)) { 452 return ''; 453 } 454 455 $causedBy = array(); 456 foreach ($missingDependencies as $dependency) { 457 $causedBy[] = ucfirst($dependency['requirement']) . ' ' . $dependency['causedBy']; 458 } 459 460 return Piwik::translate("CorePluginsAdmin_PluginRequirement", array( 461 $this->getPluginName(), 462 implode(', ', $causedBy) 463 )); 464 } 465 466 /** 467 * Schedules re-archiving of this plugin's reports from when this plugin was last 468 * deactivated to now. If the last time core:archive was run is earlier than the 469 * plugin's last deactivation time, then we use that time instead. 470 * 471 * Note: this only works for CLI archiving setups. 472 * 473 * Note: the time frame is limited by the `[General] rearchive_reports_in_past_last_n_months` 474 * INI config value. 475 * 476 * @throws \DI\DependencyException 477 * @throws \DI\NotFoundException 478 */ 479 public function schedulePluginReArchiving() 480 { 481 $lastDeactivationTime = $this->getPluginLastDeactivationTime(); 482 483 $dateTime = null; 484 485 $lastCronArchiveTime = (int) Option::get(CronArchive::OPTION_ARCHIVING_FINISHED_TS); 486 if (empty($lastCronArchiveTime)) { 487 $dateTime = $lastDeactivationTime; 488 } else if (empty($lastDeactivationTime)) { 489 $dateTime = null; // use default earliest time 490 } else { 491 $lastCronArchiveTime = Date::factory($lastCronArchiveTime); 492 $dateTime = $lastDeactivationTime->isEarlier($lastCronArchiveTime) ? $lastDeactivationTime : $lastCronArchiveTime; 493 } 494 495 if (empty($dateTime)) { // sanity check 496 $dateTime = null; 497 } 498 499 $archiveInvalidator = StaticContainer::get(ArchiveInvalidator::class); 500 $archiveInvalidator->scheduleReArchiving('all', $this->getPluginName(), $report = null, $dateTime); 501 } 502 503 /** 504 * Extracts the plugin name from a backtrace array. Returns `false` if we can't find one. 505 * 506 * @param array $backtrace The result of {@link debug_backtrace()} or 507 * [Exception::getTrace()](http://www.php.net/manual/en/exception.gettrace.php). 508 * @return string|false 509 */ 510 public static function getPluginNameFromBacktrace($backtrace) 511 { 512 foreach ($backtrace as $tracepoint) { 513 // try and discern the plugin name 514 if (isset($tracepoint['class'])) { 515 $className = self::getPluginNameFromNamespace($tracepoint['class']); 516 if ($className) { 517 return $className; 518 } 519 } 520 } 521 return false; 522 } 523 524 /** 525 * Extracts the plugin name from a namespace name or a fully qualified class name. Returns `false` 526 * if we can't find one. 527 * 528 * @param string $namespaceOrClassName The namespace or class string. 529 * @return string|false 530 */ 531 public static function getPluginNameFromNamespace($namespaceOrClassName) 532 { 533 if ($namespaceOrClassName && preg_match("/Piwik\\\\Plugins\\\\([a-zA-Z_0-9]+)\\\\/", $namespaceOrClassName, $matches)) { 534 return $matches[1]; 535 } else { 536 return false; 537 } 538 } 539 540 /** 541 * Override this method in your plugin class if you want your plugin to be loaded during tracking. 542 * 543 * Note: If you define your own dimension or handle a tracker event, your plugin will automatically 544 * be detected as a tracker plugin. 545 * 546 * @return bool 547 * @internal 548 */ 549 public function isTrackerPlugin() 550 { 551 return false; 552 } 553 554 /** 555 * @return Date|null 556 * @throws \Exception 557 */ 558 public function getPluginLastActivationTime() 559 { 560 $optionName = Manager::LAST_PLUGIN_ACTIVATION_TIME_OPTION_PREFIX . $this->pluginName; 561 $time = Option::get($optionName); 562 if (empty($time)) { 563 return null; 564 } 565 return Date::factory((int) $time); 566 } 567 568 /** 569 * @return Date|null 570 * @throws \Exception 571 */ 572 public function getPluginLastDeactivationTime() 573 { 574 $optionName = Manager::LAST_PLUGIN_DEACTIVATION_TIME_OPTION_PREFIX . $this->pluginName; 575 $time = Option::get($optionName); 576 if (empty($time)) { 577 return null; 578 } 579 return Date::factory((int) $time); 580 } 581 582 /** 583 * @param $directoryWithinPlugin 584 * @param $expectedSubclass 585 * @return array 586 */ 587 private function doFindMultipleComponents($directoryWithinPlugin, $expectedSubclass) 588 { 589 $components = array(); 590 591 $pluginsDir = Manager::getPluginDirectory($this->pluginName); 592 $baseDir = $pluginsDir . '/' . $directoryWithinPlugin; 593 594 $files = Filesystem::globr($baseDir, '*.php'); 595 596 foreach ($files as $file) { 597 require_once $file; 598 599 $fileName = str_replace(array($baseDir . '/', '.php'), '', $file); 600 $klassName = sprintf('Piwik\\Plugins\\%s\\%s\\%s', $this->pluginName, str_replace('/', '\\', $directoryWithinPlugin), str_replace('/', '\\', $fileName)); 601 602 if (!class_exists($klassName)) { 603 continue; 604 } 605 606 if (!empty($expectedSubclass) && !is_subclass_of($klassName, $expectedSubclass)) { 607 continue; 608 } 609 610 $klass = new \ReflectionClass($klassName); 611 612 if ($klass->isAbstract()) { 613 continue; 614 } 615 616 $components[$file] = $klassName; 617 } 618 return $components; 619 } 620 621 /** 622 * @param $components 623 * @return bool true if all files were included, false if any file cannot be read 624 */ 625 private function includeComponents($components) 626 { 627 foreach ($components as $file => $klass) { 628 if (!is_readable($file)) { 629 return false; 630 } 631 } 632 foreach ($components as $file => $klass) { 633 include_once $file; 634 } 635 return true; 636 } 637 638 /** 639 * @param $piwikVersion 640 * @return Dependency 641 */ 642 private function makeDependency($piwikVersion) 643 { 644 $dependency = new Dependency(); 645 646 if (!is_null($piwikVersion)) { 647 $dependency->setPiwikVersion($piwikVersion); 648 } 649 return $dependency; 650 } 651} 652 653} 654 655