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 // endregion 410 411 // region Remote Info 412 413 /** 414 * Get the date of the last available update 415 * 416 * @return string yyyy-mm-dd 417 */ 418 public function getLastUpdate() 419 { 420 return $this->getRemoteTag('lastupdate'); 421 } 422 423 /** 424 * Get a list of tags this extension is tagged with at dokuwiki.org 425 * 426 * @return string[] 427 */ 428 public function getTags() 429 { 430 return $this->getRemoteTag('tags', []); 431 } 432 433 /** 434 * Get the popularity of the extension 435 * 436 * This is a float between 0 and 1 437 * 438 * @return float 439 */ 440 public function getPopularity() 441 { 442 return (float)$this->getRemoteTag('popularity', 0); 443 } 444 445 /** 446 * Get the text of the update message if there is any 447 * 448 * @return string 449 */ 450 public function getUpdateMessage() 451 { 452 return $this->getRemoteTag('updatemessage'); 453 } 454 455 /** 456 * Get the text of the security warning if there is any 457 * 458 * @return string 459 */ 460 public function getSecurityWarning() 461 { 462 return $this->getRemoteTag('securitywarning'); 463 } 464 465 /** 466 * Get the text of the security issue if there is any 467 * 468 * @return string 469 */ 470 public function getSecurityIssue() 471 { 472 return $this->getRemoteTag('securityissue'); 473 } 474 475 /** 476 * Get the URL of the screenshot of the extension if there is any 477 * 478 * @return string 479 */ 480 public function getScreenshotURL() 481 { 482 return $this->getRemoteTag('screenshoturl'); 483 } 484 485 /** 486 * Get the URL of the thumbnail of the extension if there is any 487 * 488 * @return string 489 */ 490 public function getThumbnailURL() 491 { 492 return $this->getRemoteTag('thumbnailurl'); 493 } 494 495 /** 496 * Get the download URL of the extension if there is any 497 * 498 * @return string 499 */ 500 public function getDownloadURL() 501 { 502 return $this->getRemoteTag('downloadurl'); 503 } 504 505 /** 506 * Get the bug tracker URL of the extension if there is any 507 * 508 * @return string 509 */ 510 public function getBugtrackerURL() 511 { 512 return $this->getRemoteTag('bugtracker'); 513 } 514 515 /** 516 * Get the URL of the source repository if there is any 517 * 518 * @return string 519 */ 520 public function getSourcerepoURL() 521 { 522 return $this->getRemoteTag('sourcerepo'); 523 } 524 525 /** 526 * Get the donation URL of the extension if there is any 527 * 528 * @return string 529 */ 530 public function getDonationURL() 531 { 532 return $this->getRemoteTag('donationurl'); 533 } 534 535 // endregion 536 537 // region Actions 538 539 /** 540 * Install or update the extension 541 * 542 * @throws Exception 543 */ 544 public function installOrUpdate() 545 { 546 $installer = new Installer(true); 547 $installer->installFromUrl( 548 $this->getURL(), 549 $this->getBase(), 550 ); 551 } 552 553 /** 554 * Uninstall the extension 555 * @throws Exception 556 */ 557 public function uninstall() 558 { 559 $installer = new Installer(true); 560 $installer->uninstall($this); 561 } 562 563 /** 564 * Enable the extension 565 * @todo I'm unsure if this code should be here or part of Installer 566 * @throws Exception 567 */ 568 public function enable() 569 { 570 if ($this->isTemplate()) throw new Exception('notimplemented'); 571 if (!$this->isInstalled()) throw new Exception('notinstalled'); 572 if ($this->isEnabled()) throw new Exception('alreadyenabled'); 573 574 /* @var PluginController $plugin_controller */ 575 global $plugin_controller; 576 if (!$plugin_controller->enable($this->base)) { 577 throw new Exception('pluginlistsaveerror'); 578 } 579 Installer::purgeCache(); 580 } 581 582 /** 583 * Disable the extension 584 * @todo I'm unsure if this code should be here or part of Installer 585 * @throws Exception 586 */ 587 public function disable() 588 { 589 if ($this->isTemplate()) throw new Exception('notimplemented'); 590 if (!$this->isInstalled()) throw new Exception('notinstalled'); 591 if (!$this->isEnabled()) throw new Exception('alreadydisabled'); 592 if ($this->isProtected()) throw new Exception('error_disable_protected'); 593 594 /* @var PluginController $plugin_controller */ 595 global $plugin_controller; 596 if (!$plugin_controller->disable($this->base)) { 597 throw new Exception('pluginlistsaveerror'); 598 } 599 Installer::purgeCache(); 600 } 601 602 // endregion 603 604 // region Meta Data Management 605 606 /** 607 * Access the Manager for this extension 608 * 609 * @return Manager 610 */ 611 public function getManager() 612 { 613 if ($this->manager === null) { 614 $this->manager = new Manager($this); 615 } 616 return $this->manager; 617 } 618 619 /** 620 * Reads the info file of the extension if available and fills the localInfo array 621 */ 622 protected function readLocalInfo() 623 { 624 if (!$this->getCurrentDir()) return; 625 $file = $this->currentDir . '/' . $this->type . '.info.txt'; 626 if (!is_readable($file)) return; 627 $this->localInfo = confToHash($file, true); 628 $this->localInfo = array_filter($this->localInfo); // remove all falsy keys 629 } 630 631 /** 632 * Fetches the remote info from the repository 633 * 634 * This ignores any errors coming from the repository and just sets the remoteInfo to an empty array in that case 635 */ 636 protected function loadRemoteInfo() 637 { 638 if ($this->remoteInfo) return; 639 $remote = Repository::getInstance(); 640 try { 641 $this->remoteInfo = (array)$remote->getExtensionData($this->getId()); 642 } catch (Exception $e) { 643 $this->remoteInfo = []; 644 } 645 } 646 647 /** 648 * Read information from either local or remote info 649 * 650 * Always prefers local info over remote info. Giving multiple keys is useful when the 651 * key has been renamed in the past or if local and remote keys might differ. 652 * 653 * @param string|string[] $tag one or multiple keys to check 654 * @param mixed $default 655 * @return mixed 656 */ 657 protected function getTag($tag, $default = '') 658 { 659 foreach ((array)$tag as $t) { 660 if (isset($this->localInfo[$t])) return $this->localInfo[$t]; 661 } 662 663 return $this->getRemoteTag($tag, $default); 664 } 665 666 /** 667 * Read information from remote info 668 * 669 * @param string|string[] $tag one or mutiple keys to check 670 * @param mixed $default 671 * @return mixed 672 */ 673 protected function getRemoteTag($tag, $default = '') 674 { 675 $this->loadRemoteInfo(); 676 foreach ((array)$tag as $t) { 677 if (isset($this->remoteInfo[$t])) return $this->remoteInfo[$t]; 678 } 679 return $default; 680 } 681 682 // endregion 683 684 // region utilities 685 686 /** 687 * Convert an extension id to a type and base 688 * 689 * @param string $id 690 * @return array [type, base] 691 */ 692 protected function idToTypeBase($id) 693 { 694 [$type, $base] = sexplode(':', $id, 2); 695 if ($base === null) { 696 $base = $type; 697 $type = self::TYPE_PLUGIN; 698 } elseif ($type === self::TYPE_TEMPLATE) { 699 $type = self::TYPE_TEMPLATE; 700 } else { 701 throw new RuntimeException('Invalid extension id: ' . $id); 702 } 703 704 return [$type, $base]; 705 } 706 /** 707 * @return string 708 */ 709 public function __toString() 710 { 711 return $this->getId(); 712 } 713 714 // endregion 715} 716