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