1<?php 2 3namespace dokuwiki\plugin\extension; 4 5use dokuwiki\Extension\PluginController; 6use dokuwiki\Utf8\PhpString; 7use RuntimeException; 8 9class Extension 10{ 11 const TYPE_PLUGIN = 'plugin'; 12 const TYPE_TEMPLATE = 'template'; 13 14 /** @var string "plugin"|"template" */ 15 protected string $type = self::TYPE_PLUGIN; 16 17 /** @var string The base name of this extension */ 18 protected string $base; 19 20 /** @var string The current location of this extension */ 21 protected string $currentDir = ''; 22 23 /** @var array The local info array of the extension */ 24 protected array $localInfo = []; 25 26 /** @var array The remote info array of the extension */ 27 protected array $remoteInfo = []; 28 29 /** @var Manager|null The manager for this extension */ 30 protected ?Manager $manager = null; 31 32 // region Constructors 33 34 /** 35 * The main constructor is private to force the use of the factory methods 36 */ 37 protected function __construct() 38 { 39 } 40 41 /** 42 * Initializes an extension from an id 43 * 44 * @param string $id The id of the extension 45 * @return Extension 46 */ 47 public static function createFromId($id) 48 { 49 $extension = new self(); 50 $extension->initFromId($id); 51 return $extension; 52 } 53 54 protected function initFromId($id) 55 { 56 [$type, $base] = $this->idToTypeBase($id); 57 $this->type = $type; 58 $this->base = $base; 59 $this->readLocalInfo(); 60 } 61 62 /** 63 * Initializes an extension from a directory 64 * 65 * The given directory might be the one where the extension has already been installed to 66 * or it might be the extracted source in some temporary directory. 67 * 68 * @param string $dir Where the extension code is currently located 69 * @param string|null $type TYPE_PLUGIN|TYPE_TEMPLATE, null for auto-detection 70 * @param string $base The base name of the extension, null for auto-detection 71 * @return Extension 72 */ 73 public static function createFromDirectory($dir, $type = null, $base = null) 74 { 75 $extension = new self(); 76 $extension->initFromDirectory($dir, $type, $base); 77 return $extension; 78 } 79 80 protected function initFromDirectory($dir, $type = null, $base = null) 81 { 82 if (!is_dir($dir)) throw new RuntimeException('Directory not found: ' . $dir); 83 $this->currentDir = realpath($dir); 84 85 if ($type === null || $type === self::TYPE_TEMPLATE) { 86 if ( 87 file_exists($dir . '/template.info.php') || 88 file_exists($dir . '/style.ini') || 89 file_exists($dir . '/main.php') || 90 file_exists($dir . '/detail.php') || 91 file_exists($dir . '/mediamanager.php') 92 ) { 93 $this->type = self::TYPE_TEMPLATE; 94 } 95 } else { 96 $this->type = self::TYPE_PLUGIN; 97 } 98 99 $this->readLocalInfo(); 100 101 if ($base !== null) { 102 $this->base = $base; 103 } elseif (isset($this->localInfo['base'])) { 104 $this->base = $this->localInfo['base']; 105 } else { 106 $this->base = basename($dir); 107 } 108 } 109 110 /** 111 * Initializes an extension from remote data 112 * 113 * @param array $data The data as returned by the repository api 114 * @return Extension 115 */ 116 public static function createFromRemoteData($data) 117 { 118 $extension = new self(); 119 $extension->initFromRemoteData($data); 120 return $extension; 121 } 122 123 protected function initFromRemoteData($data) 124 { 125 if (!isset($data['plugin'])) throw new RuntimeException('Invalid remote data'); 126 127 [$type, $base] = $this->idToTypeBase($data['plugin']); 128 $this->remoteInfo = $data; 129 $this->type = $type; 130 $this->base = $base; 131 132 if ($this->isInstalled()) { 133 $this->currentDir = $this->getInstallDir(); 134 $this->readLocalInfo(); 135 } 136 } 137 138 // endregion 139 140 // region Getters 141 142 /** 143 * @param bool $wrap If true, the id is wrapped in backticks 144 * @return string The extension id (same as base but prefixed with "template:" for templates) 145 */ 146 public function getId($wrap=false) 147 { 148 if ($this->type === self::TYPE_TEMPLATE) { 149 $id = self::TYPE_TEMPLATE . ':' . $this->base; 150 } else { 151 $id = $this->base; 152 } 153 if($wrap) $id = "`$id`"; 154 return $id; 155 } 156 157 /** 158 * Get the base name of this extension 159 * 160 * @return string 161 */ 162 public function getBase() 163 { 164 return $this->base; 165 } 166 167 /** 168 * Get the type of the extension 169 * 170 * @return string "plugin"|"template" 171 */ 172 public function getType() 173 { 174 return $this->type; 175 } 176 177 /** 178 * The current directory of the extension 179 * 180 * @return string|null 181 */ 182 public function getCurrentDir() 183 { 184 // recheck that the current currentDir is still valid 185 if ($this->currentDir && !is_dir($this->currentDir)) { 186 $this->currentDir = ''; 187 } 188 189 // if the extension is installed, then the currentDir is the install dir! 190 if (!$this->currentDir && $this->isInstalled()) { 191 $this->currentDir = $this->getInstallDir(); 192 } 193 194 return $this->currentDir; 195 } 196 197 /** 198 * Get the directory where this extension should be installed in 199 * 200 * Note: this does not mean that the extension is actually installed there 201 * 202 * @return string 203 */ 204 public function getInstallDir() 205 { 206 if ($this->isTemplate()) { 207 $dir = dirname(tpl_incdir()) . '/' . $this->base; 208 } else { 209 $dir = DOKU_PLUGIN . $this->base; 210 } 211 212 return fullpath($dir); 213 } 214 215 216 /** 217 * Get the display name of the extension 218 * 219 * @return string 220 */ 221 public function getDisplayName() 222 { 223 return $this->getTag('name', PhpString::ucwords($this->getBase() . ' ' . $this->getType())); 224 } 225 226 /** 227 * Get the author name of the extension 228 * 229 * @return string Returns an empty string if the author info is missing 230 */ 231 public function getAuthor() 232 { 233 return $this->getTag('author'); 234 } 235 236 /** 237 * Get the email of the author of the extension if there is any 238 * 239 * @return string Returns an empty string if the email info is missing 240 */ 241 public function getEmail() 242 { 243 // email is only in the local data 244 return $this->localInfo['email'] ?? ''; 245 } 246 247 /** 248 * Get the email id, i.e. the md5sum of the email 249 * 250 * @return string Empty string if no email is available 251 */ 252 public function getEmailID() 253 { 254 if (!empty($this->remoteInfo['emailid'])) return $this->remoteInfo['emailid']; 255 if (!empty($this->localInfo['email'])) return md5($this->localInfo['email']); 256 return ''; 257 } 258 259 /** 260 * Get the description of the extension 261 * 262 * @return string Empty string if no description is available 263 */ 264 public function getDescription() 265 { 266 return $this->getTag(['desc', 'description']); 267 } 268 269 /** 270 * Get the URL of the extension, usually a page on dokuwiki.org 271 * 272 * @return string 273 */ 274 public function getURL() 275 { 276 return $this->getTag( 277 'url', 278 'https://www.dokuwiki.org/' . 279 ($this->isTemplate() ? 'template' : 'plugin') . ':' . $this->getBase() 280 ); 281 } 282 283 /** 284 * Get the version of the extension that is actually installed 285 * 286 * Returns an empty string if the version is not available 287 * 288 * @return string 289 */ 290 public function getInstalledVersion() 291 { 292 return $this->localInfo['date'] ?? ''; 293 } 294 295 /** 296 * Get the types of components this extension provides 297 * 298 * @todo for installed extensions this could be read from the filesystem instead of relying on the meta data 299 * @return array int -> type 300 */ 301 public function getComponentTypes () 302 { 303 return $this->getTag('types', []); 304 } 305 306 /** 307 * Get a list of extension ids this extension depends on 308 * 309 * @return string[] 310 */ 311 public function getDependencyList() 312 { 313 return $this->getTag('depends', []); 314 } 315 316 /** 317 * Return the minimum PHP version required by the extension 318 * 319 * Empty if not set 320 * 321 * @return string 322 */ 323 public function getMinimumPHPVersion() 324 { 325 return $this->getTag('phpmin', ''); 326 } 327 328 /** 329 * Return the minimum PHP version supported by the extension 330 * 331 * @return string 332 */ 333 public function getMaximumPHPVersion() 334 { 335 return $this->getTag('phpmax', ''); 336 } 337 338 /** 339 * Is this extension a template? 340 * 341 * @return bool false if it is a plugin 342 */ 343 public function isTemplate() 344 { 345 return $this->type === self::TYPE_TEMPLATE; 346 } 347 348 /** 349 * Is the extension installed locally? 350 * 351 * @return bool 352 */ 353 public function isInstalled() 354 { 355 return is_dir($this->getInstallDir()); 356 } 357 358 /** 359 * Is the extension under git control? 360 * 361 * @return bool 362 */ 363 public function isGitControlled() 364 { 365 if (!$this->isInstalled()) return false; 366 return file_exists($this->getInstallDir() . '/.git'); 367 } 368 369 /** 370 * If the extension is bundled 371 * 372 * @return bool If the extension is bundled 373 */ 374 public function isBundled() 375 { 376 $this->loadRemoteInfo(); 377 return $this->remoteInfo['bundled'] ?? in_array( 378 $this->getId(), 379 [ 380 'authad', 381 'authldap', 382 'authpdo', 383 'authplain', 384 'acl', 385 'config', 386 'extension', 387 'info', 388 'popularity', 389 'revert', 390 'safefnrecode', 391 'styling', 392 'testing', 393 'usermanager', 394 'logviewer', 395 'template:dokuwiki' 396 ] 397 ); 398 } 399 400 /** 401 * Is the extension protected against any modification (disable/uninstall) 402 * 403 * @return bool if the extension is protected 404 */ 405 public function isProtected() 406 { 407 // never allow deinstalling the current auth plugin: 408 global $conf; 409 if ($this->getId() == $conf['authtype']) return true; 410 411 // FIXME disallow current template to be uninstalled 412 413 /** @var PluginController $plugin_controller */ 414 global $plugin_controller; 415 $cascade = $plugin_controller->getCascade(); 416 return ($cascade['protected'][$this->getId()] ?? false); 417 } 418 419 /** 420 * Is the extension installed in the correct directory? 421 * 422 * @return bool 423 */ 424 public function isInWrongFolder() 425 { 426 if(!$this->isInstalled()) return false; 427 return $this->getInstallDir() != $this->currentDir; 428 } 429 430 /** 431 * Is the extension enabled? 432 * 433 * @return bool 434 */ 435 public function isEnabled() 436 { 437 global $conf; 438 if ($this->isTemplate()) { 439 return ($conf['template'] == $this->getBase()); 440 } 441 442 /* @var PluginController $plugin_controller */ 443 global $plugin_controller; 444 return $plugin_controller->isEnabled($this->base); 445 } 446 447 /** 448 * Has the download URL changed since the last download? 449 * 450 * @return bool 451 */ 452 public function hasChangedURL() 453 { 454 $last = $this->getManager()->getDownloadURL(); 455 if(!$last) return false; 456 return $last !== $this->getDownloadURL(); 457 } 458 459 /** 460 * Is an update available for this extension? 461 * 462 * @return bool 463 */ 464 public function isUpdateAvailable() 465 { 466 if($this->isBundled()) return false; // bundled extensions are never updated 467 $self = $this->getInstalledVersion(); 468 $remote = $this->getLastUpdate(); 469 return $self < $remote; 470 } 471 472 // endregion 473 474 // region Remote Info 475 476 /** 477 * Get the date of the last available update 478 * 479 * @return string yyyy-mm-dd 480 */ 481 public function getLastUpdate() 482 { 483 return $this->getRemoteTag('lastupdate'); 484 } 485 486 /** 487 * Get a list of tags this extension is tagged with at dokuwiki.org 488 * 489 * @return string[] 490 */ 491 public function getTags() 492 { 493 return $this->getRemoteTag('tags', []); 494 } 495 496 /** 497 * Get the popularity of the extension 498 * 499 * This is a float between 0 and 1 500 * 501 * @return float 502 */ 503 public function getPopularity() 504 { 505 return (float)$this->getRemoteTag('popularity', 0); 506 } 507 508 /** 509 * Get the text of the update message if there is any 510 * 511 * @return string 512 */ 513 public function getUpdateMessage() 514 { 515 return $this->getRemoteTag('updatemessage'); 516 } 517 518 /** 519 * Get the text of the security warning if there is any 520 * 521 * @return string 522 */ 523 public function getSecurityWarning() 524 { 525 return $this->getRemoteTag('securitywarning'); 526 } 527 528 /** 529 * Get the text of the security issue if there is any 530 * 531 * @return string 532 */ 533 public function getSecurityIssue() 534 { 535 return $this->getRemoteTag('securityissue'); 536 } 537 538 /** 539 * Get the URL of the screenshot of the extension if there is any 540 * 541 * @return string 542 */ 543 public function getScreenshotURL() 544 { 545 return $this->getRemoteTag('screenshoturl'); 546 } 547 548 /** 549 * Get the URL of the thumbnail of the extension if there is any 550 * 551 * @return string 552 */ 553 public function getThumbnailURL() 554 { 555 return $this->getRemoteTag('thumbnailurl'); 556 } 557 558 /** 559 * Get the download URL of the extension if there is any 560 * 561 * @return string 562 */ 563 public function getDownloadURL() 564 { 565 return $this->getRemoteTag('downloadurl'); 566 } 567 568 /** 569 * Get the bug tracker URL of the extension if there is any 570 * 571 * @return string 572 */ 573 public function getBugtrackerURL() 574 { 575 return $this->getRemoteTag('bugtracker'); 576 } 577 578 /** 579 * Get the URL of the source repository if there is any 580 * 581 * @return string 582 */ 583 public function getSourcerepoURL() 584 { 585 return $this->getRemoteTag('sourcerepo'); 586 } 587 588 /** 589 * Get the donation URL of the extension if there is any 590 * 591 * @return string 592 */ 593 public function getDonationURL() 594 { 595 return $this->getRemoteTag('donationurl'); 596 } 597 598 /** 599 * Get a list of extensions that are similar to this one 600 * 601 * @return string[] 602 */ 603 public function getSimilarList() 604 { 605 return $this->getRemoteTag('similar', []); 606 } 607 608 /** 609 * Get a list of extensions that are marked as conflicting with this one 610 * 611 * @return string[] 612 */ 613 public function getConflictList() 614 { 615 return $this->getRemoteTag('conflicts', []); 616 } 617 618 /** 619 * Get a list of DokuWiki versions this plugin is marked as compatible with 620 * 621 * @return string[][] date -> version 622 */ 623 public function getCompatibleVersions() 624 { 625 return $this->getRemoteTag('compatible', []); 626 } 627 628 // endregion 629 630 // region Actions 631 632 /** 633 * Install or update the extension 634 * 635 * @throws Exception 636 */ 637 public function installOrUpdate() 638 { 639 $installer = new Installer(true); 640 $installer->installExtension($this); 641 } 642 643 /** 644 * Uninstall the extension 645 * @throws Exception 646 */ 647 public function uninstall() 648 { 649 $installer = new Installer(true); 650 $installer->uninstall($this); 651 } 652 653 /** 654 * Enable the extension 655 * @todo I'm unsure if this code should be here or part of Installer 656 * @throws Exception 657 */ 658 public function enable() 659 { 660 if ($this->isTemplate()) throw new Exception('notimplemented'); 661 if (!$this->isInstalled()) throw new Exception('error_notinstalled', [$this->getId()]); 662 if ($this->isEnabled()) throw new Exception('error_alreadyenabled', [$this->getId()]); 663 664 /* @var PluginController $plugin_controller */ 665 global $plugin_controller; 666 if (!$plugin_controller->enable($this->base)) { 667 throw new Exception('pluginlistsaveerror'); 668 } 669 Installer::purgeCache(); 670 } 671 672 /** 673 * Disable the extension 674 * @todo I'm unsure if this code should be here or part of Installer 675 * @throws Exception 676 */ 677 public function disable() 678 { 679 if ($this->isTemplate()) throw new Exception('notimplemented'); 680 if (!$this->isInstalled()) throw new Exception('error_notinstalled', [$this->getId()]); 681 if (!$this->isEnabled()) throw new Exception('error_alreadydisabled', [$this->getId()]); 682 if ($this->isProtected()) throw new Exception('error_disable_protected', [$this->getId()]); 683 684 /* @var PluginController $plugin_controller */ 685 global $plugin_controller; 686 if (!$plugin_controller->disable($this->base)) { 687 throw new Exception('pluginlistsaveerror'); 688 } 689 Installer::purgeCache(); 690 } 691 692 // endregion 693 694 // region Meta Data Management 695 696 /** 697 * Access the Manager for this extension 698 * 699 * @return Manager 700 */ 701 public function getManager() 702 { 703 if ($this->manager === null) { 704 $this->manager = new Manager($this); 705 } 706 return $this->manager; 707 } 708 709 /** 710 * Reads the info file of the extension if available and fills the localInfo array 711 */ 712 protected function readLocalInfo() 713 { 714 if (!$this->getCurrentDir()) return; 715 $file = $this->currentDir . '/' . $this->type . '.info.txt'; 716 if (!is_readable($file)) return; 717 $this->localInfo = confToHash($file, true); 718 $this->localInfo = array_filter($this->localInfo); // remove all falsy keys 719 } 720 721 /** 722 * Fetches the remote info from the repository 723 * 724 * This ignores any errors coming from the repository and just sets the remoteInfo to an empty array in that case 725 */ 726 protected function loadRemoteInfo() 727 { 728 if ($this->remoteInfo) return; 729 $remote = Repository::getInstance(); 730 try { 731 $this->remoteInfo = (array)$remote->getExtensionData($this->getId()); 732 } catch (Exception $e) { 733 $this->remoteInfo = []; 734 } 735 } 736 737 /** 738 * Read information from either local or remote info 739 * 740 * Always prefers local info over remote info. Giving multiple keys is useful when the 741 * key has been renamed in the past or if local and remote keys might differ. 742 * 743 * @param string|string[] $tag one or multiple keys to check 744 * @param mixed $default 745 * @return mixed 746 */ 747 protected function getTag($tag, $default = '') 748 { 749 foreach ((array)$tag as $t) { 750 if (isset($this->localInfo[$t])) return $this->localInfo[$t]; 751 } 752 753 return $this->getRemoteTag($tag, $default); 754 } 755 756 /** 757 * Read information from remote info 758 * 759 * @param string|string[] $tag one or mutiple keys to check 760 * @param mixed $default 761 * @return mixed 762 */ 763 protected function getRemoteTag($tag, $default = '') 764 { 765 $this->loadRemoteInfo(); 766 foreach ((array)$tag as $t) { 767 if (isset($this->remoteInfo[$t])) return $this->remoteInfo[$t]; 768 } 769 return $default; 770 } 771 772 // endregion 773 774 // region utilities 775 776 /** 777 * Convert an extension id to a type and base 778 * 779 * @param string $id 780 * @return array [type, base] 781 */ 782 protected function idToTypeBase($id) 783 { 784 [$type, $base] = sexplode(':', $id, 2); 785 if ($base === null) { 786 $base = $type; 787 $type = self::TYPE_PLUGIN; 788 } elseif ($type === self::TYPE_TEMPLATE) { 789 $type = self::TYPE_TEMPLATE; 790 } else { 791 throw new RuntimeException('Invalid extension id: ' . $id); 792 } 793 794 return [$type, $base]; 795 } 796 /** 797 * @return string 798 */ 799 public function __toString() 800 { 801 return $this->getId(); 802 } 803 804 // endregion 805} 806