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 "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 = fullpath($dir); 84 85 if ($type === null || $type === self::TYPE_TEMPLATE) { 86 if ( 87 file_exists($dir . '/template.info.txt') || 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 * @return array int -> type 299 */ 300 public function getComponentTypes() 301 { 302 // for installed extensions we can check the files 303 if ($this->isInstalled()) { 304 if ($this->isTemplate()) { 305 return ['Template']; 306 } else { 307 $types = []; 308 foreach (['Admin', 'Action', 'Syntax', 'Renderer', 'Helper', 'CLI'] as $type) { 309 $check = strtolower($type); 310 if ( 311 file_exists($this->getInstallDir() . '/' . $check . '.php') || 312 is_dir($this->getInstallDir() . '/' . $check) 313 ) { 314 $types[] = $type; 315 } 316 } 317 return $types; 318 } 319 } 320 // still, here? use the remote info 321 return $this->getTag('types', []); 322 } 323 324 /** 325 * Get a list of extension ids this extension depends on 326 * 327 * @return string[] 328 */ 329 public function getDependencyList() 330 { 331 return $this->getTag('depends', []); 332 } 333 334 /** 335 * Get a list of extensions that are currently installed, enabled and depend on this extension 336 * 337 * @return Extension[] 338 */ 339 public function getDependants() 340 { 341 $local = new Local(); 342 $extensions = $local->getExtensions(); 343 $dependants = []; 344 foreach ($extensions as $extension) { 345 if ( 346 in_array($this->getId(), $extension->getDependencyList()) && 347 $extension->isEnabled() 348 ) { 349 $dependants[$extension->getId()] = $extension; 350 } 351 } 352 return $dependants; 353 } 354 355 /** 356 * Return the minimum PHP version required by the extension 357 * 358 * Empty if not set 359 * 360 * @return string 361 */ 362 public function getMinimumPHPVersion() 363 { 364 return $this->getTag('phpmin', ''); 365 } 366 367 /** 368 * Return the minimum PHP version supported by the extension 369 * 370 * @return string 371 */ 372 public function getMaximumPHPVersion() 373 { 374 return $this->getTag('phpmax', ''); 375 } 376 377 /** 378 * Is this extension a template? 379 * 380 * @return bool false if it is a plugin 381 */ 382 public function isTemplate() 383 { 384 return $this->type === self::TYPE_TEMPLATE; 385 } 386 387 /** 388 * Is the extension installed locally? 389 * 390 * @return bool 391 */ 392 public function isInstalled() 393 { 394 return is_dir($this->getInstallDir()); 395 } 396 397 /** 398 * Is the extension under git control? 399 * 400 * @return bool 401 */ 402 public function isGitControlled() 403 { 404 if (!$this->isInstalled()) return false; 405 return file_exists($this->getInstallDir() . '/.git'); 406 } 407 408 /** 409 * If the extension is bundled 410 * 411 * @return bool If the extension is bundled 412 */ 413 public function isBundled() 414 { 415 $this->loadRemoteInfo(); 416 return $this->remoteInfo['bundled'] ?? in_array( 417 $this->getId(), 418 [ 419 'authad', 420 'authldap', 421 'authpdo', 422 'authplain', 423 'acl', 424 'config', 425 'extension', 426 'info', 427 'popularity', 428 'revert', 429 'safefnrecode', 430 'styling', 431 'testing', 432 'usermanager', 433 'logviewer', 434 'template:dokuwiki' 435 ] 436 ); 437 } 438 439 /** 440 * Is the extension protected against any modification (disable/uninstall) 441 * 442 * @return bool if the extension is protected 443 */ 444 public function isProtected() 445 { 446 // never allow deinstalling the current auth plugin: 447 global $conf; 448 if ($this->getId() == $conf['authtype']) return true; 449 450 // disallow current template to be uninstalled 451 if ($this->isTemplate() && ($this->getBase() === $conf['template'])) return true; 452 453 /** @var PluginController $plugin_controller */ 454 global $plugin_controller; 455 $cascade = $plugin_controller->getCascade(); 456 return ($cascade['protected'][$this->getId()] ?? false); 457 } 458 459 /** 460 * Is the extension installed in the correct directory? 461 * 462 * @return bool 463 */ 464 public function isInWrongFolder() 465 { 466 if (!$this->isInstalled()) return false; 467 return $this->getInstallDir() != $this->currentDir; 468 } 469 470 /** 471 * Is the extension enabled? 472 * 473 * @return bool 474 */ 475 public function isEnabled() 476 { 477 global $conf; 478 if ($this->isTemplate()) { 479 return ($conf['template'] == $this->getBase()); 480 } 481 482 /* @var PluginController $plugin_controller */ 483 global $plugin_controller; 484 return $plugin_controller->isEnabled($this->base); 485 } 486 487 /** 488 * Has the download URL changed since the last download? 489 * 490 * @return bool 491 */ 492 public function hasChangedURL() 493 { 494 $last = $this->getManager()->getDownloadURL(); 495 if (!$last) return false; 496 return $last !== $this->getDownloadURL(); 497 } 498 499 /** 500 * Is an update available for this extension? 501 * 502 * @return bool 503 */ 504 public function isUpdateAvailable() 505 { 506 if ($this->isBundled()) return false; // bundled extensions are never updated 507 $self = $this->getInstalledVersion(); 508 $remote = $this->getLastUpdate(); 509 return $self < $remote; 510 } 511 512 // endregion 513 514 // region Remote Info 515 516 /** 517 * Get the date of the last available update 518 * 519 * @return string yyyy-mm-dd 520 */ 521 public function getLastUpdate() 522 { 523 return $this->getRemoteTag('lastupdate'); 524 } 525 526 /** 527 * Get a list of tags this extension is tagged with at dokuwiki.org 528 * 529 * @return string[] 530 */ 531 public function getTags() 532 { 533 return $this->getRemoteTag('tags', []); 534 } 535 536 /** 537 * Get the popularity of the extension 538 * 539 * This is a float between 0 and 1 540 * 541 * @return float 542 */ 543 public function getPopularity() 544 { 545 return (float)$this->getRemoteTag('popularity', 0); 546 } 547 548 /** 549 * Get the text of the update message if there is any 550 * 551 * @return string 552 */ 553 public function getUpdateMessage() 554 { 555 return $this->getRemoteTag('updatemessage'); 556 } 557 558 /** 559 * Get the text of the security warning if there is any 560 * 561 * @return string 562 */ 563 public function getSecurityWarning() 564 { 565 return $this->getRemoteTag('securitywarning'); 566 } 567 568 /** 569 * Get the text of the security issue if there is any 570 * 571 * @return string 572 */ 573 public function getSecurityIssue() 574 { 575 return $this->getRemoteTag('securityissue'); 576 } 577 578 /** 579 * Get the URL of the screenshot of the extension if there is any 580 * 581 * @return string 582 */ 583 public function getScreenshotURL() 584 { 585 return $this->getRemoteTag('screenshoturl'); 586 } 587 588 /** 589 * Get the URL of the thumbnail of the extension if there is any 590 * 591 * @return string 592 */ 593 public function getThumbnailURL() 594 { 595 return $this->getRemoteTag('thumbnailurl'); 596 } 597 598 /** 599 * Get the download URL of the extension if there is any 600 * 601 * @return string 602 */ 603 public function getDownloadURL() 604 { 605 return $this->getRemoteTag('downloadurl'); 606 } 607 608 /** 609 * Get the bug tracker URL of the extension if there is any 610 * 611 * @return string 612 */ 613 public function getBugtrackerURL() 614 { 615 return $this->getRemoteTag('bugtracker'); 616 } 617 618 /** 619 * Get the URL of the source repository if there is any 620 * 621 * @return string 622 */ 623 public function getSourcerepoURL() 624 { 625 return $this->getRemoteTag('sourcerepo'); 626 } 627 628 /** 629 * Get the donation URL of the extension if there is any 630 * 631 * @return string 632 */ 633 public function getDonationURL() 634 { 635 return $this->getRemoteTag('donationurl'); 636 } 637 638 /** 639 * Get a list of extensions that are similar to this one 640 * 641 * @return string[] 642 */ 643 public function getSimilarList() 644 { 645 return $this->getRemoteTag('similar', []); 646 } 647 648 /** 649 * Get a list of extensions that are marked as conflicting with this one 650 * 651 * @return string[] 652 */ 653 public function getConflictList() 654 { 655 return $this->getRemoteTag('conflicts', []); 656 } 657 658 /** 659 * Get a list of DokuWiki versions this plugin is marked as compatible with 660 * 661 * @return string[][] date -> version 662 */ 663 public function getCompatibleVersions() 664 { 665 return $this->getRemoteTag('compatible', []); 666 } 667 668 // endregion 669 670 // region Actions 671 672 /** 673 * Install or update the extension 674 * 675 * @throws Exception 676 */ 677 public function installOrUpdate() 678 { 679 $installer = new Installer(true); 680 $installer->installExtension($this); 681 } 682 683 /** 684 * Uninstall the extension 685 * @throws Exception 686 */ 687 public function uninstall() 688 { 689 $installer = new Installer(true); 690 $installer->uninstall($this); 691 } 692 693 /** 694 * Toggle the extension between enabled and disabled 695 * @return void 696 * @throws Exception 697 */ 698 public function toggle() 699 { 700 if ($this->isEnabled()) { 701 $this->disable(); 702 } else { 703 $this->enable(); 704 } 705 } 706 707 /** 708 * Enable the extension 709 * 710 * @throws Exception 711 */ 712 public function enable() 713 { 714 (new Installer())->enable($this); 715 } 716 717 /** 718 * Disable the extension 719 * 720 * @throws Exception 721 */ 722 public function disable() 723 { 724 (new Installer())->disable($this); 725 } 726 727 // endregion 728 729 // region Meta Data Management 730 731 /** 732 * Access the Manager for this extension 733 * 734 * @return Manager 735 */ 736 public function getManager() 737 { 738 if (!$this->manager instanceof Manager) { 739 $this->manager = new Manager($this); 740 } 741 return $this->manager; 742 } 743 744 /** 745 * Reads the info file of the extension if available and fills the localInfo array 746 */ 747 protected function readLocalInfo() 748 { 749 if (!$this->getCurrentDir()) return; 750 $file = $this->currentDir . '/' . $this->type . '.info.txt'; 751 if (!is_readable($file)) return; 752 $this->localInfo = confToHash($file, true); 753 $this->localInfo = array_filter($this->localInfo); // remove all falsy keys 754 } 755 756 /** 757 * Fetches the remote info from the repository 758 * 759 * This ignores any errors coming from the repository and just sets the remoteInfo to an empty array in that case 760 */ 761 protected function loadRemoteInfo() 762 { 763 if ($this->remoteInfo) return; 764 $remote = Repository::getInstance(); 765 try { 766 $this->remoteInfo = (array)$remote->getExtensionData($this->getId()); 767 } catch (Exception $e) { 768 $this->remoteInfo = []; 769 } 770 } 771 772 /** 773 * Read information from either local or remote info 774 * 775 * Always prefers local info over remote info. Giving multiple keys is useful when the 776 * key has been renamed in the past or if local and remote keys might differ. 777 * 778 * @param string|string[] $tag one or multiple keys to check 779 * @param mixed $default 780 * @return mixed 781 */ 782 protected function getTag($tag, $default = '') 783 { 784 foreach ((array)$tag as $t) { 785 if (isset($this->localInfo[$t])) return $this->localInfo[$t]; 786 } 787 788 return $this->getRemoteTag($tag, $default); 789 } 790 791 /** 792 * Read information from remote info 793 * 794 * @param string|string[] $tag one or mutiple keys to check 795 * @param mixed $default 796 * @return mixed 797 */ 798 protected function getRemoteTag($tag, $default = '') 799 { 800 $this->loadRemoteInfo(); 801 foreach ((array)$tag as $t) { 802 if (isset($this->remoteInfo[$t])) return $this->remoteInfo[$t]; 803 } 804 return $default; 805 } 806 807 // endregion 808 809 // region utilities 810 811 /** 812 * Convert an extension id to a type and base 813 * 814 * @param string $id 815 * @return array [type, base] 816 */ 817 protected function idToTypeBase($id) 818 { 819 [$type, $base] = sexplode(':', $id, 2); 820 if ($base === null) { 821 $base = $type; 822 $type = self::TYPE_PLUGIN; 823 } elseif ($type === self::TYPE_TEMPLATE) { 824 $type = self::TYPE_TEMPLATE; 825 } else { 826 throw new RuntimeException('Invalid extension id: ' . $id); 827 } 828 829 return [$type, $base]; 830 } 831 832 /** 833 * @return string 834 */ 835 public function __toString() 836 { 837 return $this->getId(); 838 } 839 840 // endregion 841} 842