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 a directory 43 * 44 * The given directory might be the one where the extension has already been installed to 45 * or it might be the extracted source in some temporary directory. 46 * 47 * @param string $dir Where the extension code is currently located 48 * @param string|null $type TYPE_PLUGIN|TYPE_TEMPLATE, null for auto-detection 49 * @param string $base The base name of the extension, null for auto-detection 50 * @return Extension 51 */ 52 public static function createFromDirectory($dir, $type = null, $base = null) 53 { 54 $extension = new self(); 55 $extension->initFromDirectory($dir, $type, $base); 56 return $extension; 57 } 58 59 protected function initFromDirectory($dir, $type = null, $base = null) 60 { 61 if (!is_dir($dir)) throw new RuntimeException('Directory not found: ' . $dir); 62 $this->currentDir = realpath($dir); 63 64 if ($type === null || $type === self::TYPE_TEMPLATE) { 65 if ( 66 file_exists($dir . '/template.info.php') || 67 file_exists($dir . '/style.ini') || 68 file_exists($dir . '/main.php') || 69 file_exists($dir . '/detail.php') || 70 file_exists($dir . '/mediamanager.php') 71 ) { 72 $this->type = self::TYPE_TEMPLATE; 73 } 74 } else { 75 $this->type = self::TYPE_PLUGIN; 76 } 77 78 $this->readLocalInfo(); 79 80 if ($base !== null) { 81 $this->base = $base; 82 } elseif (isset($this->localInfo['base'])) { 83 $this->base = $this->localInfo['base']; 84 } else { 85 $this->base = basename($dir); 86 } 87 } 88 89 /** 90 * Initializes an extension from remote data 91 * 92 * @param array $data The data as returned by the repository api 93 * @return Extension 94 */ 95 public static function createFromRemoteData($data) 96 { 97 $extension = new self(); 98 $extension->initFromRemoteData($data); 99 return $extension; 100 } 101 102 protected function initFromRemoteData($data) 103 { 104 if (!isset($data['plugin'])) throw new RuntimeException('Invalid remote data'); 105 106 [$type, $base] = sexplode(':', $data['plugin'], 2); 107 if ($base === null) { 108 $base = $type; 109 $type = self::TYPE_PLUGIN; 110 } else { 111 $type = self::TYPE_TEMPLATE; 112 } 113 114 $this->remoteInfo = $data; 115 $this->type = $type; 116 $this->base = $base; 117 118 if ($this->isInstalled()) { 119 $this->currentDir = $this->getInstallDir(); 120 $this->readLocalInfo(); 121 } 122 } 123 124 // endregion 125 126 // region Getters 127 128 /** 129 * @return string The extension id (same as base but prefixed with "template:" for templates) 130 */ 131 public function getId() 132 { 133 if ($this->type === self::TYPE_TEMPLATE) { 134 return self::TYPE_TEMPLATE . ':' . $this->base; 135 } 136 return $this->base; 137 } 138 139 /** 140 * Get the base name of this extension 141 * 142 * @return string 143 */ 144 public function getBase() 145 { 146 return $this->base; 147 } 148 149 /** 150 * Get the type of the extension 151 * 152 * @return string "plugin"|"template" 153 */ 154 public function getType() 155 { 156 return $this->type; 157 } 158 159 /** 160 * The current directory of the extension 161 * 162 * @return string|null 163 */ 164 public function getCurrentDir() 165 { 166 // recheck that the current currentDir is still valid 167 if ($this->currentDir && !is_dir($this->currentDir)) { 168 $this->currentDir = null; 169 } 170 171 // if the extension is installed, then the currentDir is the install dir! 172 if (!$this->currentDir && $this->isInstalled()) { 173 $this->currentDir = $this->getInstallDir(); 174 } 175 176 return $this->currentDir; 177 } 178 179 /** 180 * Get the directory where this extension should be installed in 181 * 182 * Note: this does not mean that the extension is actually installed there 183 * 184 * @return string 185 */ 186 public function getInstallDir() 187 { 188 if ($this->isTemplate()) { 189 $dir = dirname(tpl_incdir()) . $this->base; 190 } else { 191 $dir = DOKU_PLUGIN . $this->base; 192 } 193 194 return realpath($dir); 195 } 196 197 198 /** 199 * Get the display name of the extension 200 * 201 * @return string 202 */ 203 public function getDisplayName() 204 { 205 return $this->getTag('name', PhpString::ucwords($this->getBase() . ' ' . $this->getType())); 206 } 207 208 /** 209 * Get the author name of the extension 210 * 211 * @return string Returns an empty string if the author info is missing 212 */ 213 public function getAuthor() 214 { 215 return $this->getTag('author'); 216 } 217 218 /** 219 * Get the email of the author of the extension if there is any 220 * 221 * @return string Returns an empty string if the email info is missing 222 */ 223 public function getEmail() 224 { 225 // email is only in the local data 226 return $this->localInfo['email'] ?? ''; 227 } 228 229 /** 230 * Get the email id, i.e. the md5sum of the email 231 * 232 * @return string Empty string if no email is available 233 */ 234 public function getEmailID() 235 { 236 if (!empty($this->remoteInfo['emailid'])) return $this->remoteInfo['emailid']; 237 if (!empty($this->localInfo['email'])) return md5($this->localInfo['email']); 238 return ''; 239 } 240 241 /** 242 * Get the description of the extension 243 * 244 * @return string Empty string if no description is available 245 */ 246 public function getDescription() 247 { 248 return $this->getTag(['desc', 'description']); 249 } 250 251 /** 252 * Get the URL of the extension, usually a page on dokuwiki.org 253 * 254 * @return string 255 */ 256 public function getURL() 257 { 258 return $this->getTag( 259 'url', 260 'https://www.dokuwiki.org/' . 261 ($this->isTemplate() ? 'template' : 'plugin') . ':' . $this->getBase() 262 ); 263 } 264 265 /** 266 * Is this extension a template? 267 * 268 * @return bool false if it is a plugin 269 */ 270 public function isTemplate() 271 { 272 return $this->type === self::TYPE_TEMPLATE; 273 } 274 275 /** 276 * Is the extension installed locally? 277 * 278 * @return bool 279 */ 280 public function isInstalled() 281 { 282 return is_dir($this->getInstallDir()); 283 } 284 285 /** 286 * Is the extension under git control? 287 * 288 * @return bool 289 */ 290 public function isGitControlled() 291 { 292 if (!$this->isInstalled()) return false; 293 return file_exists($this->getInstallDir() . '/.git'); 294 } 295 296 /** 297 * If the extension is bundled 298 * 299 * @return bool If the extension is bundled 300 */ 301 public function isBundled() 302 { 303 $this->loadRemoteInfo(); 304 return $this->remoteInfo['bundled'] ?? in_array( 305 $this->getId(), 306 [ 307 'authad', 308 'authldap', 309 'authpdo', 310 'authplain', 311 'acl', 312 'config', 313 'extension', 314 'info', 315 'popularity', 316 'revert', 317 'safefnrecode', 318 'styling', 319 'testing', 320 'usermanager', 321 'logviewer', 322 'template:dokuwiki' 323 ] 324 ); 325 } 326 327 /** 328 * Is the extension protected against any modification (disable/uninstall) 329 * 330 * @return bool if the extension is protected 331 */ 332 public function isProtected() 333 { 334 // never allow deinstalling the current auth plugin: 335 global $conf; 336 if ($this->getId() == $conf['authtype']) return true; 337 338 // FIXME disallow current template to be uninstalled 339 340 /** @var PluginController $plugin_controller */ 341 global $plugin_controller; 342 $cascade = $plugin_controller->getCascade(); 343 return ($cascade['protected'][$this->getId()] ?? false); 344 } 345 346 /** 347 * Is the extension installed in the correct directory? 348 * 349 * @return bool 350 */ 351 public function isInWrongFolder() 352 { 353 return $this->getInstallDir() != $this->currentDir; 354 } 355 356 /** 357 * Is the extension enabled? 358 * 359 * @return bool 360 */ 361 public function isEnabled() 362 { 363 global $conf; 364 if ($this->isTemplate()) { 365 return ($conf['template'] == $this->getBase()); 366 } 367 368 /* @var PluginController $plugin_controller */ 369 global $plugin_controller; 370 return $plugin_controller->isEnabled($this->base); 371 } 372 373 // endregion 374 375 // region Actions 376 377 /** 378 * Install or update the extension 379 * 380 * @throws Exception 381 */ 382 public function installOrUpdate() 383 { 384 $installer = new Installer(true); 385 $installer->installFromUrl( 386 $this->getURL(), 387 $this->getBase(), 388 ); 389 } 390 391 /** 392 * Uninstall the extension 393 * @throws Exception 394 */ 395 public function uninstall() 396 { 397 $installer = new Installer(true); 398 $installer->uninstall($this); 399 } 400 401 /** 402 * Enable the extension 403 * @todo I'm unsure if this code should be here or part of Installer 404 * @throws Exception 405 */ 406 public function enable() 407 { 408 if ($this->isTemplate()) throw new Exception('notimplemented'); 409 if (!$this->isInstalled()) throw new Exception('notinstalled'); 410 if ($this->isEnabled()) throw new Exception('alreadyenabled'); 411 412 /* @var PluginController $plugin_controller */ 413 global $plugin_controller; 414 if (!$plugin_controller->enable($this->base)) { 415 throw new Exception('pluginlistsaveerror'); 416 } 417 Installer::purgeCache(); 418 } 419 420 /** 421 * Disable the extension 422 * @todo I'm unsure if this code should be here or part of Installer 423 * @throws Exception 424 */ 425 public function disable() 426 { 427 if ($this->isTemplate()) throw new Exception('notimplemented'); 428 if (!$this->isInstalled()) throw new Exception('notinstalled'); 429 if (!$this->isEnabled()) throw new Exception('alreadydisabled'); 430 if ($this->isProtected()) throw new Exception('error_disable_protected'); 431 432 /* @var PluginController $plugin_controller */ 433 global $plugin_controller; 434 if (!$plugin_controller->disable($this->base)) { 435 throw new Exception('pluginlistsaveerror'); 436 } 437 Installer::purgeCache(); 438 } 439 440 // endregion 441 442 // region Meta Data Management 443 444 /** 445 * This updates the timestamp and URL in the manager.dat file 446 * 447 * It is called by Installer when installing or updating an extension 448 * 449 * @param $url 450 */ 451 public function updateManagerInfo($url) 452 { 453 $this->managerInfo['downloadurl'] = $url; 454 if (isset($this->managerInfo['installed'])) { 455 // it's an update 456 $this->managerInfo['updated'] = date('r'); 457 } else { 458 // it's a new install 459 $this->managerInfo['installed'] = date('r'); 460 } 461 462 $managerpath = $this->getInstallDir() . '/manager.dat'; 463 $data = ''; 464 foreach ($this->managerInfo as $k => $v) { 465 $data .= $k . '=' . $v . DOKU_LF; 466 } 467 io_saveFile($managerpath, $data); 468 } 469 470 /** 471 * Reads the manager.dat file and fills the managerInfo array 472 */ 473 protected function readManagerInfo() 474 { 475 if ($this->managerInfo) return; 476 477 $managerpath = $this->getInstallDir() . '/manager.dat'; 478 if (!is_readable($managerpath)) return; 479 480 $file = (array)@file($managerpath); 481 foreach ($file as $line) { 482 [$key, $value] = sexplode('=', $line, 2, ''); 483 $key = trim($key); 484 $value = trim($value); 485 // backwards compatible with old plugin manager 486 if ($key == 'url') $key = 'downloadurl'; 487 $this->managerInfo[$key] = $value; 488 } 489 } 490 491 /** 492 * Reads the info file of the extension if available and fills the localInfo array 493 */ 494 protected function readLocalInfo() 495 { 496 if (!$this->currentDir) return; 497 $file = $this->currentDir . '/' . $this->type . '.info.txt'; 498 if (!is_readable($file)) return; 499 $this->localInfo = confToHash($file, true); 500 $this->localInfo = array_filter($this->localInfo); // remove all falsy keys 501 } 502 503 /** 504 * Fetches the remote info from the repository 505 * 506 * This ignores any errors coming from the repository and just sets the remoteInfo to an empty array in that case 507 */ 508 protected function loadRemoteInfo() 509 { 510 if ($this->remoteInfo) return; 511 $remote = Repository::getInstance(); 512 try { 513 $this->remoteInfo = (array)$remote->getExtensionData($this->getId()); 514 } catch (Exception $e) { 515 $this->remoteInfo = []; 516 } 517 } 518 519 /** 520 * Read information from either local or remote info 521 * 522 * Always prefers local info over remote info 523 * 524 * @param string|string[] $tag one or multiple keys to check 525 * @param mixed $default 526 * @return mixed 527 */ 528 protected function getTag($tag, $default = '') 529 { 530 foreach ((array)$tag as $t) { 531 if (isset($this->localInfo[$t])) return $this->localInfo[$t]; 532 } 533 $this->loadRemoteInfo(); 534 foreach ((array)$tag as $t) { 535 if (isset($this->remoteInfo[$t])) return $this->remoteInfo[$t]; 536 } 537 538 return $default; 539 } 540 541 // endregion 542} 543