1<?php 2 3namespace dokuwiki\plugin\extension; 4 5use dokuwiki\Extension\PluginController; 6use dokuwiki\Utf8\PhpString; 7use RuntimeException; 8 9class Extension 10{ 11 const TYPE_PLUGIN = 'plugin'; 12 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.php') || 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 * @return string The extension id (same as base but prefixed with "template:" for templates) 144 */ 145 public function getId() 146 { 147 if ($this->type === self::TYPE_TEMPLATE) { 148 return self::TYPE_TEMPLATE . ':' . $this->base; 149 } 150 return $this->base; 151 } 152 153 /** 154 * Get the base name of this extension 155 * 156 * @return string 157 */ 158 public function getBase() 159 { 160 return $this->base; 161 } 162 163 /** 164 * Get the type of the extension 165 * 166 * @return string "plugin"|"template" 167 */ 168 public function getType() 169 { 170 return $this->type; 171 } 172 173 /** 174 * The current directory of the extension 175 * 176 * @return string|null 177 */ 178 public function getCurrentDir() 179 { 180 // recheck that the current currentDir is still valid 181 if ($this->currentDir && !is_dir($this->currentDir)) { 182 $this->currentDir = ''; 183 } 184 185 // if the extension is installed, then the currentDir is the install dir! 186 if (!$this->currentDir && $this->isInstalled()) { 187 $this->currentDir = $this->getInstallDir(); 188 } 189 190 return $this->currentDir; 191 } 192 193 /** 194 * Get the directory where this extension should be installed in 195 * 196 * Note: this does not mean that the extension is actually installed there 197 * 198 * @return string 199 */ 200 public function getInstallDir() 201 { 202 if ($this->isTemplate()) { 203 $dir = dirname(tpl_incdir()) . $this->base; 204 } else { 205 $dir = DOKU_PLUGIN . $this->base; 206 } 207 208 return fullpath($dir); 209 } 210 211 212 /** 213 * Get the display name of the extension 214 * 215 * @return string 216 */ 217 public function getDisplayName() 218 { 219 return $this->getTag('name', PhpString::ucwords($this->getBase() . ' ' . $this->getType())); 220 } 221 222 /** 223 * Get the author name of the extension 224 * 225 * @return string Returns an empty string if the author info is missing 226 */ 227 public function getAuthor() 228 { 229 return $this->getTag('author'); 230 } 231 232 /** 233 * Get the email of the author of the extension if there is any 234 * 235 * @return string Returns an empty string if the email info is missing 236 */ 237 public function getEmail() 238 { 239 // email is only in the local data 240 return $this->localInfo['email'] ?? ''; 241 } 242 243 /** 244 * Get the email id, i.e. the md5sum of the email 245 * 246 * @return string Empty string if no email is available 247 */ 248 public function getEmailID() 249 { 250 if (!empty($this->remoteInfo['emailid'])) return $this->remoteInfo['emailid']; 251 if (!empty($this->localInfo['email'])) return md5($this->localInfo['email']); 252 return ''; 253 } 254 255 /** 256 * Get the description of the extension 257 * 258 * @return string Empty string if no description is available 259 */ 260 public function getDescription() 261 { 262 return $this->getTag(['desc', 'description']); 263 } 264 265 /** 266 * Get the URL of the extension, usually a page on dokuwiki.org 267 * 268 * @return string 269 */ 270 public function getURL() 271 { 272 return $this->getTag( 273 'url', 274 'https://www.dokuwiki.org/' . 275 ($this->isTemplate() ? 'template' : 'plugin') . ':' . $this->getBase() 276 ); 277 } 278 279 /** 280 * Get the version of the extension that is actually installed 281 * 282 * Returns an empty string if the version is not available 283 * 284 * @return string 285 */ 286 public function getInstalledVersion() 287 { 288 return $this->localInfo['date'] ?? ''; 289 } 290 291 /** 292 * Get a list of extension ids this extension depends on 293 * 294 * @return string[] 295 */ 296 public function getDependencyList() 297 { 298 return $this->getTag('depends', []); 299 } 300 301 /** 302 * Return the minimum PHP version required by the extension 303 * 304 * Empty if not set 305 * 306 * @return string 307 */ 308 public function getMinimumPHPVersion() 309 { 310 return $this->getTag('phpmin', ''); 311 } 312 313 /** 314 * Return the minimum PHP version supported by the extension 315 * 316 * @return string 317 */ 318 public function getMaximumPHPVersion() 319 { 320 return $this->getTag('phpmax', ''); 321 } 322 323 /** 324 * Is this extension a template? 325 * 326 * @return bool false if it is a plugin 327 */ 328 public function isTemplate() 329 { 330 return $this->type === self::TYPE_TEMPLATE; 331 } 332 333 /** 334 * Is the extension installed locally? 335 * 336 * @return bool 337 */ 338 public function isInstalled() 339 { 340 return is_dir($this->getInstallDir()); 341 } 342 343 /** 344 * Is the extension under git control? 345 * 346 * @return bool 347 */ 348 public function isGitControlled() 349 { 350 if (!$this->isInstalled()) return false; 351 return file_exists($this->getInstallDir() . '/.git'); 352 } 353 354 /** 355 * If the extension is bundled 356 * 357 * @return bool If the extension is bundled 358 */ 359 public function isBundled() 360 { 361 $this->loadRemoteInfo(); 362 return $this->remoteInfo['bundled'] ?? in_array( 363 $this->getId(), 364 [ 365 'authad', 366 'authldap', 367 'authpdo', 368 'authplain', 369 'acl', 370 'config', 371 'extension', 372 'info', 373 'popularity', 374 'revert', 375 'safefnrecode', 376 'styling', 377 'testing', 378 'usermanager', 379 'logviewer', 380 'template:dokuwiki' 381 ] 382 ); 383 } 384 385 /** 386 * Is the extension protected against any modification (disable/uninstall) 387 * 388 * @return bool if the extension is protected 389 */ 390 public function isProtected() 391 { 392 // never allow deinstalling the current auth plugin: 393 global $conf; 394 if ($this->getId() == $conf['authtype']) return true; 395 396 // FIXME disallow current template to be uninstalled 397 398 /** @var PluginController $plugin_controller */ 399 global $plugin_controller; 400 $cascade = $plugin_controller->getCascade(); 401 return ($cascade['protected'][$this->getId()] ?? false); 402 } 403 404 /** 405 * Is the extension installed in the correct directory? 406 * 407 * @return bool 408 */ 409 public function isInWrongFolder() 410 { 411 return $this->getInstallDir() != $this->currentDir; 412 } 413 414 /** 415 * Is the extension enabled? 416 * 417 * @return bool 418 */ 419 public function isEnabled() 420 { 421 global $conf; 422 if ($this->isTemplate()) { 423 return ($conf['template'] == $this->getBase()); 424 } 425 426 /* @var PluginController $plugin_controller */ 427 global $plugin_controller; 428 return $plugin_controller->isEnabled($this->base); 429 } 430 431 /** 432 * Has the download URL changed since the last download? 433 * 434 * @return bool 435 */ 436 public function hasChangedURL() 437 { 438 $last = $this->getManager()->getDownloadUrl(); 439 if(!$last) return false; 440 return $last !== $this->getDownloadURL(); 441 } 442 443 /** 444 * Is an update available for this extension? 445 * 446 * @return bool 447 */ 448 public function updateAvailable() 449 { 450 if($this->isBundled()) return false; // bundled extensions are never updated 451 $self = $this->getInstalledVersion(); 452 $remote = $this->getLastUpdate(); 453 return $self < $remote; 454 } 455 456 // endregion 457 458 // region Remote Info 459 460 /** 461 * Get the date of the last available update 462 * 463 * @return string yyyy-mm-dd 464 */ 465 public function getLastUpdate() 466 { 467 return $this->getRemoteTag('lastupdate'); 468 } 469 470 /** 471 * Get a list of tags this extension is tagged with at dokuwiki.org 472 * 473 * @return string[] 474 */ 475 public function getTags() 476 { 477 return $this->getRemoteTag('tags', []); 478 } 479 480 /** 481 * Get the popularity of the extension 482 * 483 * This is a float between 0 and 1 484 * 485 * @return float 486 */ 487 public function getPopularity() 488 { 489 return (float)$this->getRemoteTag('popularity', 0); 490 } 491 492 /** 493 * Get the text of the update message if there is any 494 * 495 * @return string 496 */ 497 public function getUpdateMessage() 498 { 499 return $this->getRemoteTag('updatemessage'); 500 } 501 502 /** 503 * Get the text of the security warning if there is any 504 * 505 * @return string 506 */ 507 public function getSecurityWarning() 508 { 509 return $this->getRemoteTag('securitywarning'); 510 } 511 512 /** 513 * Get the text of the security issue if there is any 514 * 515 * @return string 516 */ 517 public function getSecurityIssue() 518 { 519 return $this->getRemoteTag('securityissue'); 520 } 521 522 /** 523 * Get the URL of the screenshot of the extension if there is any 524 * 525 * @return string 526 */ 527 public function getScreenshotURL() 528 { 529 return $this->getRemoteTag('screenshoturl'); 530 } 531 532 /** 533 * Get the URL of the thumbnail of the extension if there is any 534 * 535 * @return string 536 */ 537 public function getThumbnailURL() 538 { 539 return $this->getRemoteTag('thumbnailurl'); 540 } 541 542 /** 543 * Get the download URL of the extension if there is any 544 * 545 * @return string 546 */ 547 public function getDownloadURL() 548 { 549 return $this->getRemoteTag('downloadurl'); 550 } 551 552 /** 553 * Get the bug tracker URL of the extension if there is any 554 * 555 * @return string 556 */ 557 public function getBugtrackerURL() 558 { 559 return $this->getRemoteTag('bugtracker'); 560 } 561 562 /** 563 * Get the URL of the source repository if there is any 564 * 565 * @return string 566 */ 567 public function getSourcerepoURL() 568 { 569 return $this->getRemoteTag('sourcerepo'); 570 } 571 572 /** 573 * Get the donation URL of the extension if there is any 574 * 575 * @return string 576 */ 577 public function getDonationURL() 578 { 579 return $this->getRemoteTag('donationurl'); 580 } 581 582 // endregion 583 584 // region Actions 585 586 /** 587 * Install or update the extension 588 * 589 * @throws Exception 590 */ 591 public function installOrUpdate() 592 { 593 $installer = new Installer(true); 594 $installer->installExtension($this); 595 } 596 597 /** 598 * Uninstall the extension 599 * @throws Exception 600 */ 601 public function uninstall() 602 { 603 $installer = new Installer(true); 604 $installer->uninstall($this); 605 } 606 607 /** 608 * Enable the extension 609 * @todo I'm unsure if this code should be here or part of Installer 610 * @throws Exception 611 */ 612 public function enable() 613 { 614 if ($this->isTemplate()) throw new Exception('notimplemented'); 615 if (!$this->isInstalled()) throw new Exception('error_notinstalled', [$this->getId()]); 616 if ($this->isEnabled()) throw new Exception('error_alreadyenabled', [$this->getId()]); 617 618 /* @var PluginController $plugin_controller */ 619 global $plugin_controller; 620 if (!$plugin_controller->enable($this->base)) { 621 throw new Exception('pluginlistsaveerror'); 622 } 623 Installer::purgeCache(); 624 } 625 626 /** 627 * Disable the extension 628 * @todo I'm unsure if this code should be here or part of Installer 629 * @throws Exception 630 */ 631 public function disable() 632 { 633 if ($this->isTemplate()) throw new Exception('notimplemented'); 634 if (!$this->isInstalled()) throw new Exception('error_notinstalled', [$this->getId()]); 635 if (!$this->isEnabled()) throw new Exception('error_alreadydisabled', [$this->getId()]); 636 if ($this->isProtected()) throw new Exception('error_disable_protected', [$this->getId()]); 637 638 /* @var PluginController $plugin_controller */ 639 global $plugin_controller; 640 if (!$plugin_controller->disable($this->base)) { 641 throw new Exception('pluginlistsaveerror'); 642 } 643 Installer::purgeCache(); 644 } 645 646 // endregion 647 648 // region Meta Data Management 649 650 /** 651 * Access the Manager for this extension 652 * 653 * @return Manager 654 */ 655 public function getManager() 656 { 657 if ($this->manager === null) { 658 $this->manager = new Manager($this); 659 } 660 return $this->manager; 661 } 662 663 /** 664 * Reads the info file of the extension if available and fills the localInfo array 665 */ 666 protected function readLocalInfo() 667 { 668 if (!$this->getCurrentDir()) return; 669 $file = $this->currentDir . '/' . $this->type . '.info.txt'; 670 if (!is_readable($file)) return; 671 $this->localInfo = confToHash($file, true); 672 $this->localInfo = array_filter($this->localInfo); // remove all falsy keys 673 } 674 675 /** 676 * Fetches the remote info from the repository 677 * 678 * This ignores any errors coming from the repository and just sets the remoteInfo to an empty array in that case 679 */ 680 protected function loadRemoteInfo() 681 { 682 if ($this->remoteInfo) return; 683 $remote = Repository::getInstance(); 684 try { 685 $this->remoteInfo = (array)$remote->getExtensionData($this->getId()); 686 } catch (Exception $e) { 687 $this->remoteInfo = []; 688 } 689 } 690 691 /** 692 * Read information from either local or remote info 693 * 694 * Always prefers local info over remote info. Giving multiple keys is useful when the 695 * key has been renamed in the past or if local and remote keys might differ. 696 * 697 * @param string|string[] $tag one or multiple keys to check 698 * @param mixed $default 699 * @return mixed 700 */ 701 protected function getTag($tag, $default = '') 702 { 703 foreach ((array)$tag as $t) { 704 if (isset($this->localInfo[$t])) return $this->localInfo[$t]; 705 } 706 707 return $this->getRemoteTag($tag, $default); 708 } 709 710 /** 711 * Read information from remote info 712 * 713 * @param string|string[] $tag one or mutiple keys to check 714 * @param mixed $default 715 * @return mixed 716 */ 717 protected function getRemoteTag($tag, $default = '') 718 { 719 $this->loadRemoteInfo(); 720 foreach ((array)$tag as $t) { 721 if (isset($this->remoteInfo[$t])) return $this->remoteInfo[$t]; 722 } 723 return $default; 724 } 725 726 // endregion 727 728 // region utilities 729 730 /** 731 * Convert an extension id to a type and base 732 * 733 * @param string $id 734 * @return array [type, base] 735 */ 736 protected function idToTypeBase($id) 737 { 738 [$type, $base] = sexplode(':', $id, 2); 739 if ($base === null) { 740 $base = $type; 741 $type = self::TYPE_PLUGIN; 742 } elseif ($type === self::TYPE_TEMPLATE) { 743 $type = self::TYPE_TEMPLATE; 744 } else { 745 throw new RuntimeException('Invalid extension id: ' . $id); 746 } 747 748 return [$type, $base]; 749 } 750 /** 751 * @return string 752 */ 753 public function __toString() 754 { 755 return $this->getId(); 756 } 757 758 // endregion 759} 760