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