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