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