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 array The manager info array of the extension */ 30 protected array $managerInfo = []; 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 * Is this extension a template? 281 * 282 * @return bool false if it is a plugin 283 */ 284 public function isTemplate() 285 { 286 return $this->type === self::TYPE_TEMPLATE; 287 } 288 289 /** 290 * Is the extension installed locally? 291 * 292 * @return bool 293 */ 294 public function isInstalled() 295 { 296 return is_dir($this->getInstallDir()); 297 } 298 299 /** 300 * Is the extension under git control? 301 * 302 * @return bool 303 */ 304 public function isGitControlled() 305 { 306 if (!$this->isInstalled()) return false; 307 return file_exists($this->getInstallDir() . '/.git'); 308 } 309 310 /** 311 * If the extension is bundled 312 * 313 * @return bool If the extension is bundled 314 */ 315 public function isBundled() 316 { 317 $this->loadRemoteInfo(); 318 return $this->remoteInfo['bundled'] ?? in_array( 319 $this->getId(), 320 [ 321 'authad', 322 'authldap', 323 'authpdo', 324 'authplain', 325 'acl', 326 'config', 327 'extension', 328 'info', 329 'popularity', 330 'revert', 331 'safefnrecode', 332 'styling', 333 'testing', 334 'usermanager', 335 'logviewer', 336 'template:dokuwiki' 337 ] 338 ); 339 } 340 341 /** 342 * Is the extension protected against any modification (disable/uninstall) 343 * 344 * @return bool if the extension is protected 345 */ 346 public function isProtected() 347 { 348 // never allow deinstalling the current auth plugin: 349 global $conf; 350 if ($this->getId() == $conf['authtype']) return true; 351 352 // FIXME disallow current template to be uninstalled 353 354 /** @var PluginController $plugin_controller */ 355 global $plugin_controller; 356 $cascade = $plugin_controller->getCascade(); 357 return ($cascade['protected'][$this->getId()] ?? false); 358 } 359 360 /** 361 * Is the extension installed in the correct directory? 362 * 363 * @return bool 364 */ 365 public function isInWrongFolder() 366 { 367 return $this->getInstallDir() != $this->currentDir; 368 } 369 370 /** 371 * Is the extension enabled? 372 * 373 * @return bool 374 */ 375 public function isEnabled() 376 { 377 global $conf; 378 if ($this->isTemplate()) { 379 return ($conf['template'] == $this->getBase()); 380 } 381 382 /* @var PluginController $plugin_controller */ 383 global $plugin_controller; 384 return $plugin_controller->isEnabled($this->base); 385 } 386 387 // endregion 388 389 // region Actions 390 391 /** 392 * Install or update the extension 393 * 394 * @throws Exception 395 */ 396 public function installOrUpdate() 397 { 398 $installer = new Installer(true); 399 $installer->installFromUrl( 400 $this->getURL(), 401 $this->getBase(), 402 ); 403 } 404 405 /** 406 * Uninstall the extension 407 * @throws Exception 408 */ 409 public function uninstall() 410 { 411 $installer = new Installer(true); 412 $installer->uninstall($this); 413 } 414 415 /** 416 * Enable the extension 417 * @todo I'm unsure if this code should be here or part of Installer 418 * @throws Exception 419 */ 420 public function enable() 421 { 422 if ($this->isTemplate()) throw new Exception('notimplemented'); 423 if (!$this->isInstalled()) throw new Exception('notinstalled'); 424 if ($this->isEnabled()) throw new Exception('alreadyenabled'); 425 426 /* @var PluginController $plugin_controller */ 427 global $plugin_controller; 428 if (!$plugin_controller->enable($this->base)) { 429 throw new Exception('pluginlistsaveerror'); 430 } 431 Installer::purgeCache(); 432 } 433 434 /** 435 * Disable the extension 436 * @todo I'm unsure if this code should be here or part of Installer 437 * @throws Exception 438 */ 439 public function disable() 440 { 441 if ($this->isTemplate()) throw new Exception('notimplemented'); 442 if (!$this->isInstalled()) throw new Exception('notinstalled'); 443 if (!$this->isEnabled()) throw new Exception('alreadydisabled'); 444 if ($this->isProtected()) throw new Exception('error_disable_protected'); 445 446 /* @var PluginController $plugin_controller */ 447 global $plugin_controller; 448 if (!$plugin_controller->disable($this->base)) { 449 throw new Exception('pluginlistsaveerror'); 450 } 451 Installer::purgeCache(); 452 } 453 454 // endregion 455 456 // region Meta Data Management 457 458 /** 459 * This updates the timestamp and URL in the manager.dat file 460 * 461 * It is called by Installer when installing or updating an extension 462 * 463 * @param $url 464 */ 465 public function updateManagerInfo($url) 466 { 467 $this->managerInfo['downloadurl'] = $url; 468 if (isset($this->managerInfo['installed'])) { 469 // it's an update 470 $this->managerInfo['updated'] = date('r'); 471 } else { 472 // it's a new install 473 $this->managerInfo['installed'] = date('r'); 474 } 475 476 $managerpath = $this->getInstallDir() . '/manager.dat'; 477 $data = ''; 478 foreach ($this->managerInfo as $k => $v) { 479 $data .= $k . '=' . $v . DOKU_LF; 480 } 481 io_saveFile($managerpath, $data); 482 } 483 484 /** 485 * Reads the manager.dat file and fills the managerInfo array 486 */ 487 protected function readManagerInfo() 488 { 489 if ($this->managerInfo) return; 490 491 $managerpath = $this->getInstallDir() . '/manager.dat'; 492 if (!is_readable($managerpath)) return; 493 494 $file = (array)@file($managerpath); 495 foreach ($file as $line) { 496 [$key, $value] = sexplode('=', $line, 2, ''); 497 $key = trim($key); 498 $value = trim($value); 499 // backwards compatible with old plugin manager 500 if ($key == 'url') $key = 'downloadurl'; 501 $this->managerInfo[$key] = $value; 502 } 503 } 504 505 /** 506 * Reads the info file of the extension if available and fills the localInfo array 507 */ 508 protected function readLocalInfo() 509 { 510 if (!$this->getCurrentDir()) return; 511 $file = $this->currentDir . '/' . $this->type . '.info.txt'; 512 if (!is_readable($file)) return; 513 $this->localInfo = confToHash($file, true); 514 $this->localInfo = array_filter($this->localInfo); // remove all falsy keys 515 } 516 517 /** 518 * Fetches the remote info from the repository 519 * 520 * This ignores any errors coming from the repository and just sets the remoteInfo to an empty array in that case 521 */ 522 protected function loadRemoteInfo() 523 { 524 if ($this->remoteInfo) return; 525 $remote = Repository::getInstance(); 526 try { 527 $this->remoteInfo = (array)$remote->getExtensionData($this->getId()); 528 } catch (Exception $e) { 529 $this->remoteInfo = []; 530 } 531 } 532 533 /** 534 * Read information from either local or remote info 535 * 536 * Always prefers local info over remote info 537 * 538 * @param string|string[] $tag one or multiple keys to check 539 * @param mixed $default 540 * @return mixed 541 */ 542 protected function getTag($tag, $default = '') 543 { 544 foreach ((array)$tag as $t) { 545 if (isset($this->localInfo[$t])) return $this->localInfo[$t]; 546 } 547 $this->loadRemoteInfo(); 548 foreach ((array)$tag as $t) { 549 if (isset($this->remoteInfo[$t])) return $this->remoteInfo[$t]; 550 } 551 552 return $default; 553 } 554 555 // endregion 556 557 // region utilities 558 559 /** 560 * Convert an extension id to a type and base 561 * 562 * @param string $id 563 * @return array [type, base] 564 */ 565 protected function idToTypeBase($id) 566 { 567 [$type, $base] = sexplode(':', $id, 2); 568 if ($base === null) { 569 $base = $type; 570 $type = self::TYPE_PLUGIN; 571 } elseif ($type === self::TYPE_TEMPLATE) { 572 $type = self::TYPE_TEMPLATE; 573 } else { 574 throw new RuntimeException('Invalid extension id: ' . $id); 575 } 576 577 return [$type, $base]; 578 } 579 // endregion 580} 581