1<?php 2 3namespace dokuwiki\plugin\extension; 4 5use dokuwiki\Extension\PluginController; 6use dokuwiki\HTTP\DokuHTTPClient; 7use dokuwiki\Utf8\PhpString; 8use RecursiveDirectoryIterator; 9use RecursiveIteratorIterator; 10use splitbrain\PHPArchive\ArchiveCorruptedException; 11use splitbrain\PHPArchive\ArchiveIllegalCompressionException; 12use splitbrain\PHPArchive\ArchiveIOException; 13use splitbrain\PHPArchive\Tar; 14use splitbrain\PHPArchive\Zip; 15 16/** 17 * Install and deinstall extensions 18 * 19 * This manages all the file operations and downloads needed to install an extension. 20 */ 21class Installer 22{ 23 /** @var string[] a list of temporary directories used during this installation */ 24 protected array $temporary = []; 25 26 /** @var bool if changes have been made that require a cache purge */ 27 protected $isDirty = false; 28 29 /** @var bool Replace existing files? */ 30 protected $overwrite = false; 31 32 /** @var string The last used URL to install an extension */ 33 protected $sourceUrl = ''; 34 35 protected $processed = []; 36 37 public const STATUS_SKIPPED = 'skipped'; 38 public const STATUS_UPDATED = 'updated'; 39 public const STATUS_INSTALLED = 'installed'; 40 public const STATUS_REMOVED = 'removed'; 41 42 /** 43 * Initialize a new extension installer 44 * 45 * @param bool $overwrite 46 */ 47 public function __construct($overwrite = false) 48 { 49 $this->overwrite = $overwrite; 50 } 51 52 /** 53 * Destructor 54 * 55 * deletes any dangling temporary directories 56 */ 57 public function __destruct() 58 { 59 foreach ($this->temporary as $dir) { 60 io_rmdir($dir, true); 61 } 62 $this->cleanUp(); 63 } 64 65 /** 66 * Install an extension by ID 67 * 68 * This will simply call installExtension after constructing an extension from the ID 69 * 70 * The $skipInstalled parameter should only be used when installing dependencies 71 * 72 * @param string $id the extension ID 73 * @param bool $skipInstalled Ignore the overwrite setting and skip installed extensions 74 * @throws Exception 75 */ 76 public function installFromId($id, $skipInstalled = false) 77 { 78 $extension = Extension::createFromId($id); 79 if ($skipInstalled && $extension->isInstalled()) return; 80 $this->installExtension($extension); 81 } 82 83 /** 84 * Install an extension 85 * 86 * This will simply call installFromUrl() with the URL from the extension 87 * 88 * @param Extension $extension 89 * @throws Exception 90 */ 91 public function installExtension(Extension $extension) 92 { 93 $url = $extension->getDownloadURL(); 94 if (!$url) { 95 throw new Exception('error_nourl', [$extension->getId()]); 96 } 97 $this->installFromUrl($url, $extension->getBase()); 98 } 99 100 /** 101 * Install extensions from a given URL 102 * 103 * @param string $url the URL to the archive 104 * @param null $base the base directory name to use 105 * @throws Exception 106 */ 107 public function installFromUrl($url, $base = null) 108 { 109 $this->sourceUrl = $url; 110 $archive = $this->downloadArchive($url); 111 $this->installFromArchive( 112 $archive, 113 $base 114 ); 115 } 116 117 /** 118 * Install extensions from a user upload 119 * 120 * @param string $field name of the upload file 121 * @throws Exception 122 */ 123 public function installFromUpload($field) 124 { 125 $this->sourceUrl = ''; 126 if ($_FILES[$field]['error']) { 127 throw new Exception('msg_upload_failed', [$_FILES[$field]['error']]); 128 } 129 130 $tmpbase = $this->fileToBase($_FILES[$field]['name']) ?: 'upload'; 131 $tmp = $this->mkTmpDir(); 132 if (!move_uploaded_file($_FILES[$field]['tmp_name'], "$tmp/$tmpbase.archive")) { 133 throw new Exception('msg_upload_failed', ['move failed']); 134 } 135 $this->installFromArchive("$tmp/$tmpbase.archive"); 136 } 137 138 /** 139 * Install extensions from an archive 140 * 141 * The archive is extracted to a temporary directory and then the contained extensions are installed. 142 * This is is the ultimate installation procedure and all other install methods will end up here. 143 * 144 * @param string $archive the path to the archive 145 * @param string $base the base directory name to use 146 * @throws Exception 147 */ 148 public function installFromArchive($archive, $base = null) 149 { 150 $target = $this->mkTmpDir() . '/' . ($base ?? $this->fileToBase($archive)); 151 $this->extractArchive($archive, $target); 152 $extensions = $this->findExtensions($target, $base); 153 foreach ($extensions as $extension) { 154 // check installation status 155 if ($extension->isInstalled()) { 156 if (!$this->overwrite) { 157 $this->processed[$extension->getId()] = self::STATUS_SKIPPED; 158 continue; 159 } 160 $status = self::STATUS_UPDATED; 161 } else { 162 $status = self::STATUS_INSTALLED; 163 } 164 165 // check PHP requirements 166 self::ensurePhpCompatibility($extension); 167 168 // install dependencies first 169 foreach ($extension->getDependencyList() as $id) { 170 if (isset($this->processed[$id])) continue; 171 if ($id == $extension->getId()) continue; // avoid circular dependencies 172 $this->installFromId($id, true); 173 } 174 175 // now install the extension 176 self::ensurePermissions($extension); 177 $this->dircopy( 178 $extension->getCurrentDir(), 179 $extension->getInstallDir() 180 ); 181 $this->isDirty = true; 182 $extension->getManager()->storeUpdate($this->sourceUrl); 183 $this->removeDeletedFiles($extension); 184 $this->processed[$extension->getId()] = $status; 185 } 186 187 $this->cleanUp(); 188 } 189 190 /** 191 * Uninstall an extension 192 * 193 * @param Extension $extension 194 * @throws Exception 195 */ 196 public function uninstall(Extension $extension) 197 { 198 if (!$extension->isInstalled()) { 199 throw new Exception('error_notinstalled', [$extension->getId()]); 200 } 201 202 if ($extension->isProtected()) { 203 throw new Exception('error_uninstall_protected', [$extension->getId()]); 204 } 205 206 self::ensurePermissions($extension); 207 208 $dependants = $extension->getDependants(); 209 if ($dependants !== []) { 210 throw new Exception('error_uninstall_dependants', [$extension->getId(), implode(', ', $dependants)]); 211 } 212 213 if (!io_rmdir($extension->getInstallDir(), true)) { 214 throw new Exception('msg_delete_failed', [$extension->getId()]); 215 } 216 self::purgeCache(); 217 218 $this->processed[$extension->getId()] = self::STATUS_REMOVED; 219 } 220 221 /** 222 * Enable the extension 223 * 224 * @throws Exception 225 */ 226 public function enable(Extension $extension) 227 { 228 if ($extension->isTemplate()) throw new Exception('notimplemented'); 229 if (!$extension->isInstalled()) throw new Exception('error_notinstalled', [$extension->getId()]); 230 if ($extension->isEnabled()) throw new Exception('error_alreadyenabled', [$extension->getId()]); 231 232 /* @var PluginController $plugin_controller */ 233 global $plugin_controller; 234 if (!$plugin_controller->enable($extension->getBase())) { 235 throw new Exception('pluginlistsaveerror'); 236 } 237 self::purgeCache(); 238 } 239 240 /** 241 * Disable the extension 242 * 243 * @throws Exception 244 */ 245 public function disable(Extension $extension) 246 { 247 if ($extension->isTemplate()) throw new Exception('notimplemented'); 248 if (!$extension->isInstalled()) throw new Exception('error_notinstalled', [$extension->getId()]); 249 if (!$extension->isEnabled()) throw new Exception('error_alreadydisabled', [$extension->getId()]); 250 if ($extension->isProtected()) throw new Exception('error_disable_protected', [$extension->getId()]); 251 252 $dependants = $extension->getDependants(); 253 if ($dependants !== []) { 254 throw new Exception('error_disable_dependants', [$extension->getId(), implode(', ', $dependants)]); 255 } 256 257 /* @var PluginController $plugin_controller */ 258 global $plugin_controller; 259 if (!$plugin_controller->disable($extension->getBase())) { 260 throw new Exception('pluginlistsaveerror'); 261 } 262 self::purgeCache(); 263 } 264 265 266 /** 267 * Download an archive to a protected path 268 * 269 * @param string $url The url to get the archive from 270 * @return string The path where the archive was saved 271 * @throws Exception 272 */ 273 public function downloadArchive($url) 274 { 275 // check the url 276 if (!preg_match('/https?:\/\//i', $url)) { 277 throw new Exception('error_badurl'); 278 } 279 280 // try to get the file from the path (used as plugin name fallback) 281 $file = parse_url($url, PHP_URL_PATH); 282 $file = $file ? PhpString::basename($file) : md5($url); 283 284 // download 285 $http = new DokuHTTPClient(); 286 $http->max_bodysize = 0; 287 $http->keep_alive = false; // we do single ops here, no need for keep-alive 288 $http->agent = 'DokuWiki HTTP Client (Extension Manager)'; 289 290 // large downloads may take a while on slow connections, so we try to extend the timeout to 4 minutes 291 // 4 minutes was chosen, because HTTP servers and proxies often have a 5 minute timeout 292 if (PHP_SAPI === 'cli' || @set_time_limit(60 * 4)) { 293 $http->timeout = 60 * 4 - 5; // nearly 4 minutes 294 } else { 295 $http->timeout = 25; // max. 25 sec (a bit less than default execution time) 296 } 297 298 $data = $http->get($url); 299 if ($data === false) throw new Exception('error_download', [$url, $http->error, $http->status]); 300 301 // get filename from headers 302 if ( 303 preg_match( 304 '/attachment;\s*filename\s*=\s*"([^"]*)"/i', 305 (string)($http->resp_headers['content-disposition'] ?? ''), 306 $match 307 ) 308 ) { 309 $file = PhpString::basename($match[1]); 310 } 311 312 // clean up filename 313 $file = $this->fileToBase($file); 314 315 // create tmp directory for download 316 $tmp = $this->mkTmpDir(); 317 318 // save the file 319 if (@file_put_contents("$tmp/$file", $data) === false) { 320 throw new Exception('error_save'); 321 } 322 323 return "$tmp/$file"; 324 } 325 326 327 /** 328 * Delete outdated files 329 */ 330 public function removeDeletedFiles(Extension $extension) 331 { 332 $extensiondir = $extension->getInstallDir(); 333 $definitionfile = $extensiondir . '/deleted.files'; 334 if (!file_exists($definitionfile)) return; 335 336 $list = file($definitionfile); 337 foreach ($list as $line) { 338 $line = trim(preg_replace('/#.*$/', '', $line)); 339 $line = str_replace('..', '', $line); // do not run out of the extension directory 340 if (!$line) continue; 341 342 $file = $extensiondir . '/' . $line; 343 if (!file_exists($file)) continue; 344 345 io_rmdir($file, true); 346 } 347 } 348 349 /** 350 * Purge all caches 351 */ 352 public static function purgeCache() 353 { 354 // expire dokuwiki caches 355 // touching local.php expires wiki page, JS and CSS caches 356 global $config_cascade; 357 @touch(reset($config_cascade['main']['local'])); 358 359 if (function_exists('opcache_reset')) { 360 @opcache_reset(); 361 } 362 } 363 364 /** 365 * Get the list of processed extensions and their status during an installation run 366 * 367 * @return array id => status 368 */ 369 public function getProcessed() 370 { 371 return $this->processed; 372 } 373 374 375 /** 376 * Ensure that the given extension is compatible with the current PHP version 377 * 378 * Throws an exception if the extension is not compatible 379 * 380 * @param Extension $extension 381 * @throws Exception 382 */ 383 public static function ensurePhpCompatibility(Extension $extension) 384 { 385 $min = $extension->getMinimumPHPVersion(); 386 if ($min && version_compare(PHP_VERSION, $min, '<')) { 387 throw new Exception('error_minphp', [$extension->getId(), $min, PHP_VERSION]); 388 } 389 390 $max = $extension->getMaximumPHPVersion(); 391 if ($max && version_compare(PHP_VERSION, $max, '>')) { 392 throw new Exception('error_maxphp', [$extension->getId(), $max, PHP_VERSION]); 393 } 394 } 395 396 /** 397 * Ensure the file permissions are correct before attempting to install 398 * 399 * @throws Exception if the permissions are not correct 400 */ 401 public static function ensurePermissions(Extension $extension) 402 { 403 $target = $extension->getInstallDir(); 404 405 // bundled plugins do not need to be writable 406 if ($extension->isBundled()) { 407 return; 408 } 409 410 // updates 411 if (file_exists($target)) { 412 if (!is_writable($target)) throw new Exception('noperms'); 413 return; 414 } 415 416 // new installs 417 $target = dirname($target); 418 if (!is_writable($target)) { 419 if ($extension->isTemplate()) throw new Exception('notplperms'); 420 throw new Exception('nopluginperms'); 421 } 422 } 423 424 /** 425 * Get a base name from an archive name (we don't trust) 426 * 427 * @param string $file 428 * @return string 429 */ 430 protected function fileToBase($file) 431 { 432 $base = PhpString::basename($file); 433 $base = preg_replace('/\.(tar\.gz|tar\.bz|tar\.bz2|tar|tgz|tbz|zip|archive)$/', '', $base); 434 return preg_replace('/\W+/', '', $base); 435 } 436 437 /** 438 * Returns a temporary directory 439 * 440 * The directory is registered for cleanup when the class is destroyed 441 * 442 * @return string 443 * @throws Exception 444 */ 445 protected function mkTmpDir() 446 { 447 try { 448 $dir = io_mktmpdir(); 449 } catch (\Exception $e) { 450 throw new Exception('error_dircreate', [], $e); 451 } 452 if (!$dir) throw new Exception('error_dircreate'); 453 $this->temporary[] = $dir; 454 return $dir; 455 } 456 457 /** 458 * Find all extensions in a given directory 459 * 460 * This allows us to install extensions from archives that contain multiple extensions and 461 * also caters for the fact that archives may or may not contain subdirectories for the extension(s). 462 * 463 * @param string $dir 464 * @return Extension[] 465 */ 466 protected function findExtensions($dir, $base = null) 467 { 468 // first check for plugin.info.txt or template.info.txt 469 $extensions = []; 470 $iterator = new RecursiveDirectoryIterator($dir); 471 foreach (new RecursiveIteratorIterator($iterator) as $file) { 472 if ( 473 $file->getFilename() === 'plugin.info.txt' || 474 $file->getFilename() === 'template.info.txt' 475 ) { 476 $extensions[] = Extension::createFromDirectory($file->getPath()); 477 } 478 } 479 if ($extensions) return $extensions; 480 481 // still nothing? we assume this to be a single extension that is either 482 // directly in the given directory or in single subdirectory 483 $files = glob($dir . '/*'); 484 if (count($files) === 1 && is_dir($files[0])) { 485 $dir = $files[0]; 486 } 487 return [Extension::createFromDirectory($dir, null, $base)]; 488 } 489 490 /** 491 * Extract the given archive to the given target directory 492 * 493 * Auto-guesses the archive type 494 * @throws Exception 495 */ 496 protected function extractArchive($archive, $target) 497 { 498 $fh = fopen($archive, 'rb'); 499 if (!$fh) throw new Exception('error_archive_read', [$archive]); 500 $magic = fread($fh, 5); 501 fclose($fh); 502 503 if (strpos($magic, "\x50\x4b\x03\x04") === 0) { 504 $archiver = new Zip(); 505 } else { 506 $archiver = new Tar(); 507 } 508 try { 509 $archiver->open($archive); 510 $archiver->extract($target); 511 } catch (ArchiveIOException | ArchiveCorruptedException | ArchiveIllegalCompressionException $e) { 512 throw new Exception('error_archive_extract', [$archive, $e->getMessage()], $e); 513 } 514 } 515 516 /** 517 * Copy with recursive sub-directory support 518 * 519 * @param string $src filename path to file 520 * @param string $dst filename path to file 521 * @throws Exception 522 */ 523 protected function dircopy($src, $dst) 524 { 525 global $conf; 526 527 if (is_dir($src)) { 528 if (!$dh = @opendir($src)) { 529 throw new Exception('error_copy_read', [$src]); 530 } 531 532 if (io_mkdir_p($dst)) { 533 while (false !== ($f = readdir($dh))) { 534 if ($f == '..' || $f == '.') continue; 535 $this->dircopy("$src/$f", "$dst/$f"); 536 } 537 } else { 538 throw new Exception('error_copy_mkdir', [$dst]); 539 } 540 541 closedir($dh); 542 } else { 543 $existed = file_exists($dst); 544 545 if (!@copy($src, $dst)) { 546 throw new Exception('error_copy_copy', [$src, $dst]); 547 } 548 if (!$existed && $conf['fperm']) chmod($dst, $conf['fperm']); 549 @touch($dst, filemtime($src)); 550 } 551 } 552 553 /** 554 * Reset caches if needed 555 */ 556 protected function cleanUp() 557 { 558 if ($this->isDirty) { 559 self::purgeCache(); 560 $this->isDirty = false; 561 } 562 } 563} 564