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