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 $url = $this->getDownloadURL(); 511 if (!$url) return false; 512 return $last !== $url; 513 } 514 515 /** 516 * Is an update available for this extension? 517 * 518 * @return bool 519 */ 520 public function isUpdateAvailable() 521 { 522 if ($this->isBundled()) return false; // bundled extensions are never updated 523 $self = $this->getInstalledVersion(); 524 $remote = $this->getLastUpdate(); 525 return $self < $remote; 526 } 527 528 // endregion 529 530 // region Remote Info 531 532 /** 533 * Get the date of the last available update 534 * 535 * @return string yyyy-mm-dd 536 */ 537 public function getLastUpdate() 538 { 539 return $this->getRemoteTag('lastupdate'); 540 } 541 542 /** 543 * Get a list of tags this extension is tagged with at dokuwiki.org 544 * 545 * @return string[] 546 */ 547 public function getTags() 548 { 549 return $this->getRemoteTag('tags', []); 550 } 551 552 /** 553 * Get the popularity of the extension 554 * 555 * This is a float between 0 and 1 556 * 557 * @return float 558 */ 559 public function getPopularity() 560 { 561 return (float)$this->getRemoteTag('popularity', 0); 562 } 563 564 /** 565 * Get the text of the update message if there is any 566 * 567 * @return string 568 */ 569 public function getUpdateMessage() 570 { 571 return $this->getRemoteTag('updatemessage'); 572 } 573 574 /** 575 * Get the text of the security warning if there is any 576 * 577 * @return string 578 */ 579 public function getSecurityWarning() 580 { 581 return $this->getRemoteTag('securitywarning'); 582 } 583 584 /** 585 * Get the text of the security issue if there is any 586 * 587 * @return string 588 */ 589 public function getSecurityIssue() 590 { 591 return $this->getRemoteTag('securityissue'); 592 } 593 594 /** 595 * Get the URL of the screenshot of the extension if there is any 596 * 597 * @return string 598 */ 599 public function getScreenshotURL() 600 { 601 return $this->getRemoteTag('screenshoturl'); 602 } 603 604 /** 605 * Get the URL of the thumbnail of the extension if there is any 606 * 607 * @return string 608 */ 609 public function getThumbnailURL() 610 { 611 return $this->getRemoteTag('thumbnailurl'); 612 } 613 614 /** 615 * Get the download URL of the extension if there is any 616 * 617 * @return string 618 */ 619 public function getDownloadURL() 620 { 621 return $this->getRemoteTag('downloadurl'); 622 } 623 624 /** 625 * Get the bug tracker URL of the extension if there is any 626 * 627 * @return string 628 */ 629 public function getBugtrackerURL() 630 { 631 return $this->getRemoteTag('bugtracker'); 632 } 633 634 /** 635 * Get the URL of the source repository if there is any 636 * 637 * @return string 638 */ 639 public function getSourcerepoURL() 640 { 641 return $this->getRemoteTag('sourcerepo'); 642 } 643 644 /** 645 * Get the donation URL of the extension if there is any 646 * 647 * @return string 648 */ 649 public function getDonationURL() 650 { 651 return $this->getRemoteTag('donationurl'); 652 } 653 654 /** 655 * Get a list of extensions that are similar to this one 656 * 657 * @return string[] 658 */ 659 public function getSimilarList() 660 { 661 return $this->getRemoteTag('similar', []); 662 } 663 664 /** 665 * Get a list of extensions that are marked as conflicting with this one 666 * 667 * @return string[] 668 */ 669 public function getConflictList() 670 { 671 return $this->getRemoteTag('conflicts', []); 672 } 673 674 /** 675 * Get a list of DokuWiki versions this plugin is marked as compatible with 676 * 677 * @return string[][] date -> version 678 */ 679 public function getCompatibleVersions() 680 { 681 return $this->getRemoteTag('compatible', []); 682 } 683 684 // endregion 685 686 // region Actions 687 688 /** 689 * Install or update the extension 690 * 691 * @throws Exception 692 */ 693 public function installOrUpdate() 694 { 695 $installer = new Installer(true); 696 $installer->installExtension($this); 697 } 698 699 /** 700 * Uninstall the extension 701 * @throws Exception 702 */ 703 public function uninstall() 704 { 705 $installer = new Installer(true); 706 $installer->uninstall($this); 707 } 708 709 /** 710 * Toggle the extension between enabled and disabled 711 * @return void 712 * @throws Exception 713 */ 714 public function toggle() 715 { 716 if ($this->isEnabled()) { 717 $this->disable(); 718 } else { 719 $this->enable(); 720 } 721 } 722 723 /** 724 * Enable the extension 725 * 726 * @throws Exception 727 */ 728 public function enable() 729 { 730 (new Installer())->enable($this); 731 } 732 733 /** 734 * Disable the extension 735 * 736 * @throws Exception 737 */ 738 public function disable() 739 { 740 (new Installer())->disable($this); 741 } 742 743 // endregion 744 745 // region Meta Data Management 746 747 /** 748 * Access the Manager for this extension 749 * 750 * @return Manager 751 */ 752 public function getManager() 753 { 754 if (!$this->manager instanceof Manager) { 755 $this->manager = new Manager($this); 756 } 757 return $this->manager; 758 } 759 760 /** 761 * Reads the info file of the extension if available and fills the localInfo array 762 */ 763 protected function readLocalInfo() 764 { 765 if (!$this->getCurrentDir()) return; 766 $file = $this->currentDir . '/' . $this->type . '.info.txt'; 767 if (!is_readable($file)) return; 768 $this->localInfo = confToHash($file, true); 769 $this->localInfo = array_filter($this->localInfo); // remove all falsy keys 770 } 771 772 /** 773 * Fetches the remote info from the repository 774 * 775 * This ignores any errors coming from the repository and just sets the remoteInfo to an empty array in that case 776 */ 777 protected function loadRemoteInfo() 778 { 779 if ($this->remoteInfo) return; 780 $remote = Repository::getInstance(); 781 try { 782 $this->remoteInfo = (array)$remote->getExtensionData($this->getId()); 783 } catch (Exception $e) { 784 $this->remoteInfo = []; 785 } 786 } 787 788 /** 789 * Read information from either local or remote info 790 * 791 * Always prefers local info over remote info. Giving multiple keys is useful when the 792 * key has been renamed in the past or if local and remote keys might differ. 793 * 794 * @param string|string[] $tag one or multiple keys to check 795 * @param mixed $default 796 * @return mixed 797 */ 798 protected function getTag($tag, $default = '') 799 { 800 foreach ((array)$tag as $t) { 801 if (isset($this->localInfo[$t])) return $this->localInfo[$t]; 802 } 803 804 return $this->getRemoteTag($tag, $default); 805 } 806 807 /** 808 * Read information from remote info 809 * 810 * @param string|string[] $tag one or mutiple keys to check 811 * @param mixed $default 812 * @return mixed 813 */ 814 protected function getRemoteTag($tag, $default = '') 815 { 816 $this->loadRemoteInfo(); 817 foreach ((array)$tag as $t) { 818 if (isset($this->remoteInfo[$t])) return $this->remoteInfo[$t]; 819 } 820 return $default; 821 } 822 823 // endregion 824 825 // region utilities 826 827 /** 828 * Convert an extension id to a type and base 829 * 830 * @param string $id 831 * @return array [type, base] 832 */ 833 protected function idToTypeBase($id) 834 { 835 [$type, $base] = sexplode(':', $id, 2); 836 if ($base === null) { 837 $base = $type; 838 $type = self::TYPE_PLUGIN; 839 } elseif ($type === self::TYPE_TEMPLATE) { 840 $type = self::TYPE_TEMPLATE; 841 } else { 842 throw new RuntimeException('Invalid extension id: ' . $id); 843 } 844 845 return [$type, $base]; 846 } 847 848 /** 849 * @return string 850 */ 851 public function __toString() 852 { 853 return $this->getId(); 854 } 855 856 // endregion 857} 858