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