1<?php 2 3namespace dokuwiki\plugin\extension; 4 5use dokuwiki\HTTP\DokuHTTPClient; 6use dokuwiki\Utf8\PhpString; 7use RecursiveDirectoryIterator; 8use RecursiveIteratorIterator; 9use splitbrain\PHPArchive\ArchiveCorruptedException; 10use splitbrain\PHPArchive\ArchiveIllegalCompressionException; 11use splitbrain\PHPArchive\ArchiveIOException; 12use splitbrain\PHPArchive\Tar; 13use splitbrain\PHPArchive\Zip; 14 15/** 16 * Install and deinstall extensions 17 * 18 * This manages all the file operations and downloads needed to install an extension. 19 */ 20class Installer 21{ 22 /** @var string[] a list of temporary directories used during this installation */ 23 protected array $temporary = []; 24 25 /** @var bool if changes have been made that require a cache purge */ 26 protected $isDirty = false; 27 28 /** @var bool Replace existing files? */ 29 protected $overwrite = false; 30 31 /** @var string The last used URL to install an extension */ 32 protected $sourceUrl = ''; 33 34 protected $processed = []; 35 36 public const STATUS_SKIPPED = 'skipped'; 37 public const STATUS_UPDATED = 'updated'; 38 public const STATUS_INSTALLED = 'installed'; 39 public const STATUS_REMOVED = 'removed'; 40 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); 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 $tmp = $this->mkTmpDir(); 131 if (!move_uploaded_file($_FILES[$field]['tmp_name'], "$tmp/upload.archive")) { 132 throw new Exception('msg_upload_failed', ['move failed']); 133 } 134 $this->installFromArchive( 135 "$tmp/upload.archive", 136 $this->fileToBase($_FILES[$field]['name']), 137 ); 138 } 139 140 /** 141 * Install extensions from an archive 142 * 143 * The archive is extracted to a temporary directory and then the contained extensions are installed. 144 * This is is the ultimate installation procedure and all other install methods will end up here. 145 * 146 * @param string $archive the path to the archive 147 * @param string $base the base directory name to use 148 * @throws Exception 149 */ 150 public function installFromArchive($archive, $base = null) 151 { 152 if ($base === null) $base = $this->fileToBase($archive); 153 $target = $this->mkTmpDir() . '/' . $base; 154 $this->extractArchive($archive, $target); 155 $extensions = $this->findExtensions($target, $base); 156 foreach ($extensions as $extension) { 157 // check installation status 158 if ($extension->isInstalled()) { 159 if (!$this->overwrite) { 160 $this->processed[$extension->getId()] = self::STATUS_SKIPPED; 161 continue; 162 } 163 $status = self::STATUS_UPDATED; 164 } else { 165 $status = self::STATUS_INSTALLED; 166 } 167 168 // check PHP requirements 169 self::ensurePhpCompatibility($extension); 170 171 // install dependencies first 172 foreach ($extension->getDependencyList() as $id) { 173 if (isset($this->processed[$id])) continue; 174 if ($id == $extension->getId()) continue; // avoid circular dependencies 175 $this->installFromId($id, true); 176 } 177 178 // now install the extension 179 self::ensurePermissions($extension); 180 $this->dircopy( 181 $extension->getCurrentDir(), 182 $extension->getInstallDir() 183 ); 184 $this->isDirty = true; 185 $extension->getManager()->storeUpdate($this->sourceUrl); 186 $this->removeDeletedFiles($extension); 187 $this->processed[$extension->getId()] = $status; 188 } 189 190 $this->cleanUp(); 191 } 192 193 /** 194 * Uninstall an extension 195 * 196 * @param Extension $extension 197 * @throws Exception 198 */ 199 public function uninstall(Extension $extension) 200 { 201 // FIXME check if dependencies are still needed 202 // FIXME check if other extensions depend on this one 203 204 if (!$extension->isInstalled()) { 205 throw new Exception('error_notinstalled', [$extension->getId()]); 206 } 207 208 if ($extension->isProtected()) { 209 throw new Exception('error_uninstall_protected', [$extension->getId()]); 210 } 211 212 self::ensurePermissions($extension); 213 214 if (!io_rmdir($extension->getInstallDir(), true)) { 215 throw new Exception('msg_delete_failed', [$extension->getId()]); 216 } 217 self::purgeCache(); 218 219 $this->processed[$extension->getId()] = self::STATUS_REMOVED; 220 } 221 222 /** 223 * Download an archive to a protected path 224 * 225 * @param string $url The url to get the archive from 226 * @return string The path where the archive was saved 227 * @throws Exception 228 */ 229 public function downloadArchive($url) 230 { 231 // check the url 232 if (!preg_match('/https?:\/\//i', $url)) { 233 throw new Exception('error_badurl'); 234 } 235 236 // try to get the file from the path (used as plugin name fallback) 237 $file = parse_url($url, PHP_URL_PATH); 238 $file = $file ? PhpString::basename($file) : md5($url); 239 240 // download 241 $http = new DokuHTTPClient(); 242 $http->max_bodysize = 0; 243 $http->timeout = 25; //max. 25 sec 244 $http->keep_alive = false; // we do single ops here, no need for keep-alive 245 $http->agent = 'DokuWiki HTTP Client (Extension Manager)'; 246 247 $data = $http->get($url); 248 if ($data === false) throw new Exception('error_download', [$url, $http->error, $http->status]); 249 250 // get filename from headers 251 if ( 252 preg_match( 253 '/attachment;\s*filename\s*=\s*"([^"]*)"/i', 254 (string)($http->resp_headers['content-disposition'] ?? ''), 255 $match 256 ) 257 ) { 258 $file = PhpString::basename($match[1]); 259 } 260 261 // clean up filename 262 $file = $this->fileToBase($file); 263 264 // create tmp directory for download 265 $tmp = $this->mkTmpDir(); 266 267 // save the file 268 if (@file_put_contents("$tmp/$file", $data) === false) { 269 throw new Exception('error_save'); 270 } 271 272 return "$tmp/$file"; 273 } 274 275 276 /** 277 * Delete outdated files 278 */ 279 public function removeDeletedFiles(Extension $extension) 280 { 281 $extensiondir = $extension->getInstallDir(); 282 $definitionfile = $extensiondir . '/deleted.files'; 283 if (!file_exists($definitionfile)) return; 284 285 $list = file($definitionfile); 286 foreach ($list as $line) { 287 $line = trim(preg_replace('/#.*$/', '', $line)); 288 $line = str_replace('..', '', $line); // do not run out of the extension directory 289 if (!$line) continue; 290 291 $file = $extensiondir . '/' . $line; 292 if (!file_exists($file)) continue; 293 294 io_rmdir($file, true); 295 } 296 } 297 298 /** 299 * Purge all caches 300 */ 301 public static function purgeCache() 302 { 303 // expire dokuwiki caches 304 // touching local.php expires wiki page, JS and CSS caches 305 global $config_cascade; 306 @touch(reset($config_cascade['main']['local'])); 307 308 if (function_exists('opcache_reset')) { 309 opcache_reset(); 310 } 311 } 312 313 /** 314 * Get the list of processed extensions and their status during an installation run 315 * 316 * @return array id => status 317 */ 318 public function getProcessed() 319 { 320 return $this->processed; 321 } 322 323 324 /** 325 * Ensure that the given extension is compatible with the current PHP version 326 * 327 * Throws an exception if the extension is not compatible 328 * 329 * @param Extension $extension 330 * @throws Exception 331 */ 332 public static function ensurePhpCompatibility(Extension $extension) 333 { 334 $min = $extension->getMinimumPHPVersion(); 335 if ($min && version_compare(PHP_VERSION, $min, '<')) { 336 throw new Exception('error_minphp', [$extension->getId(), $min, PHP_VERSION]); 337 } 338 339 $max = $extension->getMaximumPHPVersion(); 340 if ($max && version_compare(PHP_VERSION, $max, '>')) { 341 throw new Exception('error_maxphp', [$extension->getId(), $max, PHP_VERSION]); 342 } 343 } 344 345 /** 346 * Ensure the file permissions are correct before attempting to install 347 * 348 * @throws Exception if the permissions are not correct 349 */ 350 public static function ensurePermissions(Extension $extension) 351 { 352 $target = $extension->getInstallDir(); 353 354 // updates 355 if (file_exists($target)) { 356 if (!is_writable($target)) throw new Exception('noperms'); 357 return; 358 } 359 360 // new installs 361 $target = dirname($target); 362 if (!is_writable($target)) { 363 if ($extension->isTemplate()) throw new Exception('notplperms'); 364 throw new Exception('nopluginperms'); 365 } 366 } 367 368 /** 369 * Get a base name from an archive name (we don't trust) 370 * 371 * @param string $file 372 * @return string 373 */ 374 protected function fileToBase($file) 375 { 376 $base = PhpString::basename($file); 377 $base = preg_replace('/\.(tar\.gz|tar\.bz|tar\.bz2|tar|tgz|tbz|zip)$/', '', $base); 378 return preg_replace('/\W+/', '', $base); 379 } 380 381 /** 382 * Returns a temporary directory 383 * 384 * The directory is registered for cleanup when the class is destroyed 385 * 386 * @return string 387 * @throws Exception 388 */ 389 protected function mkTmpDir() 390 { 391 try { 392 $dir = io_mktmpdir(); 393 } catch (\Exception $e) { 394 throw new Exception('error_dircreate', [], $e); 395 } 396 if (!$dir) throw new Exception('error_dircreate'); 397 $this->temporary[] = $dir; 398 return $dir; 399 } 400 401 /** 402 * Find all extensions in a given directory 403 * 404 * This allows us to install extensions from archives that contain multiple extensions and 405 * also caters for the fact that archives may or may not contain subdirectories for the extension(s). 406 * 407 * @param string $dir 408 * @return Extension[] 409 */ 410 protected function findExtensions($dir, $base = null) 411 { 412 // first check for plugin.info.txt or template.info.txt 413 $extensions = []; 414 $iterator = new RecursiveDirectoryIterator($dir); 415 foreach (new RecursiveIteratorIterator($iterator) as $file) { 416 if ( 417 $file->getFilename() === 'plugin.info.txt' || 418 $file->getFilename() === 'template.info.txt' 419 ) { 420 $extensions[] = Extension::createFromDirectory($file->getPath()); 421 } 422 } 423 if ($extensions) return $extensions; 424 425 // still nothing? we assume this to be a single extension that is either 426 // directly in the given directory or in single subdirectory 427 $files = glob($dir . '/*'); 428 if (count($files) === 1 && is_dir($files[0])) { 429 $dir = $files[0]; 430 } 431 $base ??= PhpString::basename($dir); 432 return [Extension::createFromDirectory($dir, null, $base)]; 433 } 434 435 /** 436 * Extract the given archive to the given target directory 437 * 438 * Auto-guesses the archive type 439 * @throws Exception 440 */ 441 protected function extractArchive($archive, $target) 442 { 443 $fh = fopen($archive, 'rb'); 444 if (!$fh) throw new Exception('error_archive_read', [$archive]); 445 $magic = fread($fh, 5); 446 fclose($fh); 447 448 if (strpos($magic, "\x50\x4b\x03\x04") === 0) { 449 $archiver = new Zip(); 450 } else { 451 $archiver = new Tar(); 452 } 453 try { 454 $archiver->open($archive); 455 $archiver->extract($target); 456 } catch (ArchiveIOException | ArchiveCorruptedException | ArchiveIllegalCompressionException $e) { 457 throw new Exception('error_archive_extract', [$archive, $e->getMessage()], $e); 458 } 459 } 460 461 /** 462 * Copy with recursive sub-directory support 463 * 464 * @param string $src filename path to file 465 * @param string $dst filename path to file 466 * @throws Exception 467 */ 468 protected function dircopy($src, $dst) 469 { 470 global $conf; 471 472 if (is_dir($src)) { 473 if (!$dh = @opendir($src)) { 474 throw new Exception('error_copy_read', [$src]); 475 } 476 477 if (io_mkdir_p($dst)) { 478 while (false !== ($f = readdir($dh))) { 479 if ($f == '..' || $f == '.') continue; 480 $this->dircopy("$src/$f", "$dst/$f"); 481 } 482 } else { 483 throw new Exception('error_copy_mkdir', [$dst]); 484 } 485 486 closedir($dh); 487 } else { 488 $existed = file_exists($dst); 489 490 if (!@copy($src, $dst)) { 491 throw new Exception('error_copy_copy', [$src, $dst]); 492 } 493 if (!$existed && $conf['fperm']) chmod($dst, $conf['fperm']); 494 @touch($dst, filemtime($src)); 495 } 496 } 497 498 /** 499 * Reset caches if needed 500 */ 501 protected function cleanUp() 502 { 503 if ($this->isDirty) { 504 self::purgeCache(); 505 $this->isDirty = false; 506 } 507 } 508} 509