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