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