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