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