1cf2dcf1bSAndreas Gohr<?php 2cf2dcf1bSAndreas Gohr 3cf2dcf1bSAndreas Gohrnamespace dokuwiki\plugin\extension; 4cf2dcf1bSAndreas Gohr 5b69d74f1SAndreas Gohruse dokuwiki\Extension\PluginController; 6cf2dcf1bSAndreas Gohruse dokuwiki\HTTP\DokuHTTPClient; 7cf2dcf1bSAndreas Gohruse dokuwiki\Utf8\PhpString; 8cf2dcf1bSAndreas Gohruse RecursiveDirectoryIterator; 9cf2dcf1bSAndreas Gohruse RecursiveIteratorIterator; 10cf2dcf1bSAndreas Gohruse splitbrain\PHPArchive\ArchiveCorruptedException; 11cf2dcf1bSAndreas Gohruse splitbrain\PHPArchive\ArchiveIllegalCompressionException; 12cf2dcf1bSAndreas Gohruse splitbrain\PHPArchive\ArchiveIOException; 13cf2dcf1bSAndreas Gohruse splitbrain\PHPArchive\Tar; 14cf2dcf1bSAndreas Gohruse splitbrain\PHPArchive\Zip; 15cf2dcf1bSAndreas Gohr 16cf2dcf1bSAndreas Gohr/** 17cf2dcf1bSAndreas Gohr * Install and deinstall extensions 18cf2dcf1bSAndreas Gohr * 19cf2dcf1bSAndreas Gohr * This manages all the file operations and downloads needed to install an extension. 20cf2dcf1bSAndreas Gohr */ 21cf2dcf1bSAndreas Gohrclass Installer 22cf2dcf1bSAndreas Gohr{ 23cf2dcf1bSAndreas Gohr /** @var string[] a list of temporary directories used during this installation */ 24cf2dcf1bSAndreas Gohr protected array $temporary = []; 25cf2dcf1bSAndreas Gohr 26cf2dcf1bSAndreas Gohr /** @var bool if changes have been made that require a cache purge */ 27cf2dcf1bSAndreas Gohr protected $isDirty = false; 28cf2dcf1bSAndreas Gohr 29cf2dcf1bSAndreas Gohr /** @var bool Replace existing files? */ 30cf2dcf1bSAndreas Gohr protected $overwrite = false; 31cf2dcf1bSAndreas Gohr 32cf2dcf1bSAndreas Gohr /** @var string The last used URL to install an extension */ 33cf2dcf1bSAndreas Gohr protected $sourceUrl = ''; 34cf2dcf1bSAndreas Gohr 3525d28a01SAndreas Gohr protected $processed = []; 3625d28a01SAndreas Gohr 3725d28a01SAndreas Gohr public const STATUS_SKIPPED = 'skipped'; 3825d28a01SAndreas Gohr public const STATUS_UPDATED = 'updated'; 3925d28a01SAndreas Gohr public const STATUS_INSTALLED = 'installed'; 4080bc92fbSAndreas Gohr public const STATUS_REMOVED = 'removed'; 4125d28a01SAndreas Gohr 4225d28a01SAndreas Gohr 43cf2dcf1bSAndreas Gohr /** 44cf2dcf1bSAndreas Gohr * Initialize a new extension installer 45cf2dcf1bSAndreas Gohr * 46cf2dcf1bSAndreas Gohr * @param bool $overwrite 47cf2dcf1bSAndreas Gohr */ 48cf2dcf1bSAndreas Gohr public function __construct($overwrite = false) 49cf2dcf1bSAndreas Gohr { 50cf2dcf1bSAndreas Gohr $this->overwrite = $overwrite; 51cf2dcf1bSAndreas Gohr } 52cf2dcf1bSAndreas Gohr 53cf2dcf1bSAndreas Gohr /** 54cf2dcf1bSAndreas Gohr * Destructor 55cf2dcf1bSAndreas Gohr * 56cf2dcf1bSAndreas Gohr * deletes any dangling temporary directories 57cf2dcf1bSAndreas Gohr */ 58cf2dcf1bSAndreas Gohr public function __destruct() 59cf2dcf1bSAndreas Gohr { 6025d28a01SAndreas Gohr foreach ($this->temporary as $dir) { 6125d28a01SAndreas Gohr io_rmdir($dir, true); 6225d28a01SAndreas Gohr } 63cf2dcf1bSAndreas Gohr $this->cleanUp(); 64cf2dcf1bSAndreas Gohr } 65cf2dcf1bSAndreas Gohr 66cf2dcf1bSAndreas Gohr /** 67160d3688SAndreas Gohr * Install an extension by ID 6825d28a01SAndreas Gohr * 69160d3688SAndreas Gohr * This will simply call installExtension after constructing an extension from the ID 7025d28a01SAndreas Gohr * 7125d28a01SAndreas Gohr * The $skipInstalled parameter should only be used when installing dependencies 7225d28a01SAndreas Gohr * 7325d28a01SAndreas Gohr * @param string $id the extension ID 7425d28a01SAndreas Gohr * @param bool $skipInstalled Ignore the overwrite setting and skip installed extensions 7525d28a01SAndreas Gohr * @throws Exception 7625d28a01SAndreas Gohr */ 77160d3688SAndreas Gohr public function installFromId($id, $skipInstalled = false) 78160d3688SAndreas Gohr { 7925d28a01SAndreas Gohr $extension = Extension::createFromId($id); 8025d28a01SAndreas Gohr if ($skipInstalled && $extension->isInstalled()) return; 81160d3688SAndreas Gohr $this->installExtension($extension); 82160d3688SAndreas Gohr } 83160d3688SAndreas Gohr 84160d3688SAndreas Gohr /** 85160d3688SAndreas Gohr * Install an extension 86160d3688SAndreas Gohr * 87160d3688SAndreas Gohr * This will simply call installFromUrl() with the URL from the extension 88160d3688SAndreas Gohr * 89160d3688SAndreas Gohr * @param Extension $extension 90160d3688SAndreas Gohr * @throws Exception 91160d3688SAndreas Gohr */ 92160d3688SAndreas Gohr public function installExtension(Extension $extension) 93160d3688SAndreas Gohr { 9425d28a01SAndreas Gohr $url = $extension->getDownloadURL(); 9525d28a01SAndreas Gohr if (!$url) { 9625d28a01SAndreas Gohr throw new Exception('error_nourl', [$extension->getId()]); 9725d28a01SAndreas Gohr } 9825d28a01SAndreas Gohr $this->installFromUrl($url); 9925d28a01SAndreas Gohr } 10025d28a01SAndreas Gohr 10125d28a01SAndreas Gohr /** 102cf2dcf1bSAndreas Gohr * Install extensions from a given URL 103cf2dcf1bSAndreas Gohr * 104cf2dcf1bSAndreas Gohr * @param string $url the URL to the archive 105cf2dcf1bSAndreas Gohr * @param null $base the base directory name to use 106cf2dcf1bSAndreas Gohr * @throws Exception 107cf2dcf1bSAndreas Gohr */ 108cf2dcf1bSAndreas Gohr public function installFromUrl($url, $base = null) 109cf2dcf1bSAndreas Gohr { 110cf2dcf1bSAndreas Gohr $this->sourceUrl = $url; 111cf2dcf1bSAndreas Gohr $archive = $this->downloadArchive($url); 112cf2dcf1bSAndreas Gohr $this->installFromArchive( 113cf2dcf1bSAndreas Gohr $archive, 114cf2dcf1bSAndreas Gohr $base 115cf2dcf1bSAndreas Gohr ); 116cf2dcf1bSAndreas Gohr } 117cf2dcf1bSAndreas Gohr 118cf2dcf1bSAndreas Gohr /** 119cf2dcf1bSAndreas Gohr * Install extensions from a user upload 120cf2dcf1bSAndreas Gohr * 121cf2dcf1bSAndreas Gohr * @param string $field name of the upload file 122cf2dcf1bSAndreas Gohr * @throws Exception 123cf2dcf1bSAndreas Gohr */ 124cf2dcf1bSAndreas Gohr public function installFromUpload($field) 125cf2dcf1bSAndreas Gohr { 126cf2dcf1bSAndreas Gohr $this->sourceUrl = ''; 127cf2dcf1bSAndreas Gohr if ($_FILES[$field]['error']) { 128cf2dcf1bSAndreas Gohr throw new Exception('msg_upload_failed', [$_FILES[$field]['error']]); 129cf2dcf1bSAndreas Gohr } 130cf2dcf1bSAndreas Gohr 131cf2dcf1bSAndreas Gohr $tmp = $this->mkTmpDir(); 132cf2dcf1bSAndreas Gohr if (!move_uploaded_file($_FILES[$field]['tmp_name'], "$tmp/upload.archive")) { 133cf2dcf1bSAndreas Gohr throw new Exception('msg_upload_failed', ['move failed']); 134cf2dcf1bSAndreas Gohr } 135cf2dcf1bSAndreas Gohr $this->installFromArchive( 136cf2dcf1bSAndreas Gohr "$tmp/upload.archive", 137cf2dcf1bSAndreas Gohr $this->fileToBase($_FILES[$field]['name']), 138cf2dcf1bSAndreas Gohr ); 139cf2dcf1bSAndreas Gohr } 140cf2dcf1bSAndreas Gohr 141cf2dcf1bSAndreas Gohr /** 142cf2dcf1bSAndreas Gohr * Install extensions from an archive 143cf2dcf1bSAndreas Gohr * 144cf2dcf1bSAndreas Gohr * The archive is extracted to a temporary directory and then the contained extensions are installed. 145cf2dcf1bSAndreas Gohr * This is is the ultimate installation procedure and all other install methods will end up here. 146cf2dcf1bSAndreas Gohr * 147cf2dcf1bSAndreas Gohr * @param string $archive the path to the archive 148cf2dcf1bSAndreas Gohr * @param string $base the base directory name to use 149cf2dcf1bSAndreas Gohr * @throws Exception 150cf2dcf1bSAndreas Gohr */ 151cf2dcf1bSAndreas Gohr public function installFromArchive($archive, $base = null) 152cf2dcf1bSAndreas Gohr { 153cf2dcf1bSAndreas Gohr if ($base === null) $base = $this->fileToBase($archive); 154cf2dcf1bSAndreas Gohr $target = $this->mkTmpDir() . '/' . $base; 155cf2dcf1bSAndreas Gohr $this->extractArchive($archive, $target); 156cf2dcf1bSAndreas Gohr $extensions = $this->findExtensions($target, $base); 157cf2dcf1bSAndreas Gohr foreach ($extensions as $extension) { 15825d28a01SAndreas Gohr // check installation status 15925d28a01SAndreas Gohr if ($extension->isInstalled()) { 16025d28a01SAndreas Gohr if (!$this->overwrite) { 16125d28a01SAndreas Gohr $this->processed[$extension->getId()] = self::STATUS_SKIPPED; 162cf2dcf1bSAndreas Gohr continue; 163cf2dcf1bSAndreas Gohr } 16425d28a01SAndreas Gohr $status = self::STATUS_UPDATED; 16525d28a01SAndreas Gohr } else { 16625d28a01SAndreas Gohr $status = self::STATUS_INSTALLED; 16725d28a01SAndreas Gohr } 168cf2dcf1bSAndreas Gohr 169b2a05b76SAndreas Gohr // check PHP requirements 1704fd6a1d7SAndreas Gohr self::ensurePhpCompatibility($extension); 17125d28a01SAndreas Gohr 17225d28a01SAndreas Gohr // install dependencies first 17325d28a01SAndreas Gohr foreach ($extension->getDependencyList() as $id) { 17425d28a01SAndreas Gohr if (isset($this->processed[$id])) continue; 17525d28a01SAndreas Gohr if ($id == $extension->getId()) continue; // avoid circular dependencies 17625d28a01SAndreas Gohr $this->installFromId($id, true); 17725d28a01SAndreas Gohr } 17825d28a01SAndreas Gohr 17925d28a01SAndreas Gohr // now install the extension 1804fd6a1d7SAndreas Gohr self::ensurePermissions($extension); 181cf2dcf1bSAndreas Gohr $this->dircopy( 182cf2dcf1bSAndreas Gohr $extension->getCurrentDir(), 183cf2dcf1bSAndreas Gohr $extension->getInstallDir() 184cf2dcf1bSAndreas Gohr ); 185cf2dcf1bSAndreas Gohr $this->isDirty = true; 1867c9966a5SAndreas Gohr $extension->getManager()->storeUpdate($this->sourceUrl); 187cf2dcf1bSAndreas Gohr $this->removeDeletedFiles($extension); 18825d28a01SAndreas Gohr $this->processed[$extension->getId()] = $status; 189cf2dcf1bSAndreas Gohr } 190cf2dcf1bSAndreas Gohr 191cf2dcf1bSAndreas Gohr $this->cleanUp(); 192cf2dcf1bSAndreas Gohr } 193cf2dcf1bSAndreas Gohr 194cf2dcf1bSAndreas Gohr /** 195cf2dcf1bSAndreas Gohr * Uninstall an extension 196cf2dcf1bSAndreas Gohr * 197cf2dcf1bSAndreas Gohr * @param Extension $extension 198cf2dcf1bSAndreas Gohr * @throws Exception 199cf2dcf1bSAndreas Gohr */ 200cf2dcf1bSAndreas Gohr public function uninstall(Extension $extension) 201cf2dcf1bSAndreas Gohr { 202160d3688SAndreas Gohr if (!$extension->isInstalled()) { 203160d3688SAndreas Gohr throw new Exception('error_notinstalled', [$extension->getId()]); 204160d3688SAndreas Gohr } 205160d3688SAndreas Gohr 206cf2dcf1bSAndreas Gohr if ($extension->isProtected()) { 207cf2dcf1bSAndreas Gohr throw new Exception('error_uninstall_protected', [$extension->getId()]); 208cf2dcf1bSAndreas Gohr } 209cf2dcf1bSAndreas Gohr 2104fd6a1d7SAndreas Gohr self::ensurePermissions($extension); 2114fd6a1d7SAndreas Gohr 212b69d74f1SAndreas Gohr $dependants = $extension->getDependants(); 213b69d74f1SAndreas Gohr if ($dependants !== []) { 214b69d74f1SAndreas Gohr throw new Exception('error_uninstall_dependants', [$extension->getId(), implode(', ', $dependants)]); 215b69d74f1SAndreas Gohr } 216b69d74f1SAndreas Gohr 217cf2dcf1bSAndreas Gohr if (!io_rmdir($extension->getInstallDir(), true)) { 218cf2dcf1bSAndreas Gohr throw new Exception('msg_delete_failed', [$extension->getId()]); 219cf2dcf1bSAndreas Gohr } 220cf2dcf1bSAndreas Gohr self::purgeCache(); 22180bc92fbSAndreas Gohr 22280bc92fbSAndreas Gohr $this->processed[$extension->getId()] = self::STATUS_REMOVED; 223cf2dcf1bSAndreas Gohr } 224cf2dcf1bSAndreas Gohr 225cf2dcf1bSAndreas Gohr /** 226b69d74f1SAndreas Gohr * Enable the extension 227b69d74f1SAndreas Gohr * 228b69d74f1SAndreas Gohr * @throws Exception 229b69d74f1SAndreas Gohr */ 230b69d74f1SAndreas Gohr public function enable(Extension $extension) 231b69d74f1SAndreas Gohr { 232b69d74f1SAndreas Gohr if ($extension->isTemplate()) throw new Exception('notimplemented'); 233b69d74f1SAndreas Gohr if (!$extension->isInstalled()) throw new Exception('error_notinstalled', [$extension->getId()]); 234b69d74f1SAndreas Gohr if ($extension->isEnabled()) throw new Exception('error_alreadyenabled', [$extension->getId()]); 235b69d74f1SAndreas Gohr 236b69d74f1SAndreas Gohr /* @var PluginController $plugin_controller */ 237b69d74f1SAndreas Gohr global $plugin_controller; 238b69d74f1SAndreas Gohr if (!$plugin_controller->enable($extension->getBase())) { 239b69d74f1SAndreas Gohr throw new Exception('pluginlistsaveerror'); 240b69d74f1SAndreas Gohr } 241b69d74f1SAndreas Gohr self::purgeCache(); 242b69d74f1SAndreas Gohr } 243b69d74f1SAndreas Gohr 244b69d74f1SAndreas Gohr /** 245b69d74f1SAndreas Gohr * Disable the extension 246b69d74f1SAndreas Gohr * 247b69d74f1SAndreas Gohr * @throws Exception 248b69d74f1SAndreas Gohr */ 249b69d74f1SAndreas Gohr public function disable(Extension $extension) 250b69d74f1SAndreas Gohr { 251b69d74f1SAndreas Gohr if ($extension->isTemplate()) throw new Exception('notimplemented'); 252b69d74f1SAndreas Gohr if (!$extension->isInstalled()) throw new Exception('error_notinstalled', [$extension->getId()]); 253b69d74f1SAndreas Gohr if (!$extension->isEnabled()) throw new Exception('error_alreadydisabled', [$extension->getId()]); 254b69d74f1SAndreas Gohr if ($extension->isProtected()) throw new Exception('error_disable_protected', [$extension->getId()]); 255b69d74f1SAndreas Gohr 256b69d74f1SAndreas Gohr $dependants = $extension->getDependants(); 257b69d74f1SAndreas Gohr if ($dependants !== []) { 258b69d74f1SAndreas Gohr throw new Exception('error_disable_dependants', [$extension->getId(), implode(', ', $dependants)]); 259b69d74f1SAndreas Gohr } 260b69d74f1SAndreas Gohr 261b69d74f1SAndreas Gohr /* @var PluginController $plugin_controller */ 262b69d74f1SAndreas Gohr global $plugin_controller; 263b69d74f1SAndreas Gohr if (!$plugin_controller->disable($extension->getBase())) { 264b69d74f1SAndreas Gohr throw new Exception('pluginlistsaveerror'); 265b69d74f1SAndreas Gohr } 266b69d74f1SAndreas Gohr self::purgeCache(); 267b69d74f1SAndreas Gohr } 268b69d74f1SAndreas Gohr 269b69d74f1SAndreas Gohr 270b69d74f1SAndreas Gohr /** 271cf2dcf1bSAndreas Gohr * Download an archive to a protected path 272cf2dcf1bSAndreas Gohr * 273cf2dcf1bSAndreas Gohr * @param string $url The url to get the archive from 274cf2dcf1bSAndreas Gohr * @return string The path where the archive was saved 275cf2dcf1bSAndreas Gohr * @throws Exception 276cf2dcf1bSAndreas Gohr */ 277cf2dcf1bSAndreas Gohr public function downloadArchive($url) 278cf2dcf1bSAndreas Gohr { 279cf2dcf1bSAndreas Gohr // check the url 280cf2dcf1bSAndreas Gohr if (!preg_match('/https?:\/\//i', $url)) { 281cf2dcf1bSAndreas Gohr throw new Exception('error_badurl'); 282cf2dcf1bSAndreas Gohr } 283cf2dcf1bSAndreas Gohr 284cf2dcf1bSAndreas Gohr // try to get the file from the path (used as plugin name fallback) 285cf2dcf1bSAndreas Gohr $file = parse_url($url, PHP_URL_PATH); 286cf2dcf1bSAndreas Gohr $file = $file ? PhpString::basename($file) : md5($url); 287cf2dcf1bSAndreas Gohr 288cf2dcf1bSAndreas Gohr // download 289cf2dcf1bSAndreas Gohr $http = new DokuHTTPClient(); 290cf2dcf1bSAndreas Gohr $http->max_bodysize = 0; 291cf2dcf1bSAndreas Gohr $http->keep_alive = false; // we do single ops here, no need for keep-alive 292cf2dcf1bSAndreas Gohr $http->agent = 'DokuWiki HTTP Client (Extension Manager)'; 293cf2dcf1bSAndreas Gohr 294f17690f7SAndreas Gohr // large downloads may take a while on slow connections, so we try to extend the timeout to 4 minutes 295f17690f7SAndreas Gohr // 4 minutes was chosen, because HTTP servers and proxies often have a 5 minute timeout 296811d6efaSsplitbrain if (PHP_SAPI === 'cli' || @set_time_limit(60 * 4)) { 297f17690f7SAndreas Gohr $http->timeout = 60 * 4 - 5; // nearly 4 minutes 298f17690f7SAndreas Gohr } else { 299f17690f7SAndreas Gohr $http->timeout = 25; // max. 25 sec (a bit less than default execution time) 300f17690f7SAndreas Gohr } 301f17690f7SAndreas Gohr 302cf2dcf1bSAndreas Gohr $data = $http->get($url); 303cf2dcf1bSAndreas Gohr if ($data === false) throw new Exception('error_download', [$url, $http->error, $http->status]); 304cf2dcf1bSAndreas Gohr 305cf2dcf1bSAndreas Gohr // get filename from headers 3067c184cfcSAndreas Gohr if ( 3077c184cfcSAndreas Gohr preg_match( 308cf2dcf1bSAndreas Gohr '/attachment;\s*filename\s*=\s*"([^"]*)"/i', 309cf2dcf1bSAndreas Gohr (string)($http->resp_headers['content-disposition'] ?? ''), 310cf2dcf1bSAndreas Gohr $match 3117c184cfcSAndreas Gohr ) 3127c184cfcSAndreas Gohr ) { 313cf2dcf1bSAndreas Gohr $file = PhpString::basename($match[1]); 314cf2dcf1bSAndreas Gohr } 315cf2dcf1bSAndreas Gohr 316cf2dcf1bSAndreas Gohr // clean up filename 317cf2dcf1bSAndreas Gohr $file = $this->fileToBase($file); 318cf2dcf1bSAndreas Gohr 319cf2dcf1bSAndreas Gohr // create tmp directory for download 320cf2dcf1bSAndreas Gohr $tmp = $this->mkTmpDir(); 321cf2dcf1bSAndreas Gohr 322cf2dcf1bSAndreas Gohr // save the file 323cf2dcf1bSAndreas Gohr if (@file_put_contents("$tmp/$file", $data) === false) { 324cf2dcf1bSAndreas Gohr throw new Exception('error_save'); 325cf2dcf1bSAndreas Gohr } 326cf2dcf1bSAndreas Gohr 327cf2dcf1bSAndreas Gohr return "$tmp/$file"; 328cf2dcf1bSAndreas Gohr } 329cf2dcf1bSAndreas Gohr 330cf2dcf1bSAndreas Gohr 331cf2dcf1bSAndreas Gohr /** 332cf2dcf1bSAndreas Gohr * Delete outdated files 333cf2dcf1bSAndreas Gohr */ 334cf2dcf1bSAndreas Gohr public function removeDeletedFiles(Extension $extension) 335cf2dcf1bSAndreas Gohr { 336cf2dcf1bSAndreas Gohr $extensiondir = $extension->getInstallDir(); 337cf2dcf1bSAndreas Gohr $definitionfile = $extensiondir . '/deleted.files'; 338cf2dcf1bSAndreas Gohr if (!file_exists($definitionfile)) return; 339cf2dcf1bSAndreas Gohr 340cf2dcf1bSAndreas Gohr $list = file($definitionfile); 341cf2dcf1bSAndreas Gohr foreach ($list as $line) { 342cf2dcf1bSAndreas Gohr $line = trim(preg_replace('/#.*$/', '', $line)); 343cf2dcf1bSAndreas Gohr $line = str_replace('..', '', $line); // do not run out of the extension directory 344cf2dcf1bSAndreas Gohr if (!$line) continue; 345cf2dcf1bSAndreas Gohr 346cf2dcf1bSAndreas Gohr $file = $extensiondir . '/' . $line; 347cf2dcf1bSAndreas Gohr if (!file_exists($file)) continue; 348cf2dcf1bSAndreas Gohr 349cf2dcf1bSAndreas Gohr io_rmdir($file, true); 350cf2dcf1bSAndreas Gohr } 351cf2dcf1bSAndreas Gohr } 352cf2dcf1bSAndreas Gohr 35325d28a01SAndreas Gohr /** 35425d28a01SAndreas Gohr * Purge all caches 35525d28a01SAndreas Gohr */ 356cf2dcf1bSAndreas Gohr public static function purgeCache() 357cf2dcf1bSAndreas Gohr { 358cf2dcf1bSAndreas Gohr // expire dokuwiki caches 359cf2dcf1bSAndreas Gohr // touching local.php expires wiki page, JS and CSS caches 360cf2dcf1bSAndreas Gohr global $config_cascade; 361cf2dcf1bSAndreas Gohr @touch(reset($config_cascade['main']['local'])); 362cf2dcf1bSAndreas Gohr 363cf2dcf1bSAndreas Gohr if (function_exists('opcache_reset')) { 364e206a495SAndreas Gohr @opcache_reset(); 365cf2dcf1bSAndreas Gohr } 366cf2dcf1bSAndreas Gohr } 367cf2dcf1bSAndreas Gohr 368cf2dcf1bSAndreas Gohr /** 369160d3688SAndreas Gohr * Get the list of processed extensions and their status during an installation run 37025d28a01SAndreas Gohr * 37125d28a01SAndreas Gohr * @return array id => status 37225d28a01SAndreas Gohr */ 37325d28a01SAndreas Gohr public function getProcessed() 37425d28a01SAndreas Gohr { 37525d28a01SAndreas Gohr return $this->processed; 37625d28a01SAndreas Gohr } 37725d28a01SAndreas Gohr 378b2a05b76SAndreas Gohr 379b2a05b76SAndreas Gohr /** 380b2a05b76SAndreas Gohr * Ensure that the given extension is compatible with the current PHP version 381b2a05b76SAndreas Gohr * 382b2a05b76SAndreas Gohr * Throws an exception if the extension is not compatible 383b2a05b76SAndreas Gohr * 384b2a05b76SAndreas Gohr * @param Extension $extension 385b2a05b76SAndreas Gohr * @throws Exception 386b2a05b76SAndreas Gohr */ 3874fd6a1d7SAndreas Gohr public static function ensurePhpCompatibility(Extension $extension) 388b2a05b76SAndreas Gohr { 389b2a05b76SAndreas Gohr $min = $extension->getMinimumPHPVersion(); 390b2a05b76SAndreas Gohr if ($min && version_compare(PHP_VERSION, $min, '<')) { 391b2a05b76SAndreas Gohr throw new Exception('error_minphp', [$extension->getId(), $min, PHP_VERSION]); 392b2a05b76SAndreas Gohr } 393b2a05b76SAndreas Gohr 394b2a05b76SAndreas Gohr $max = $extension->getMaximumPHPVersion(); 395b2a05b76SAndreas Gohr if ($max && version_compare(PHP_VERSION, $max, '>')) { 396b2a05b76SAndreas Gohr throw new Exception('error_maxphp', [$extension->getId(), $max, PHP_VERSION]); 397b2a05b76SAndreas Gohr } 398b2a05b76SAndreas Gohr } 399b2a05b76SAndreas Gohr 4004fd6a1d7SAndreas Gohr /** 4014fd6a1d7SAndreas Gohr * Ensure the file permissions are correct before attempting to install 4024fd6a1d7SAndreas Gohr * 4034fd6a1d7SAndreas Gohr * @throws Exception if the permissions are not correct 4044fd6a1d7SAndreas Gohr */ 4054fd6a1d7SAndreas Gohr public static function ensurePermissions(Extension $extension) 4064fd6a1d7SAndreas Gohr { 4074fd6a1d7SAndreas Gohr $target = $extension->getInstallDir(); 4084fd6a1d7SAndreas Gohr 409*396ae2b1SAndreas Gohr // bundled plugins do not need to be writable 410*396ae2b1SAndreas Gohr if ($extension->isBundled()) { 411*396ae2b1SAndreas Gohr return; 412*396ae2b1SAndreas Gohr } 413*396ae2b1SAndreas Gohr 4144fd6a1d7SAndreas Gohr // updates 4154fd6a1d7SAndreas Gohr if (file_exists($target)) { 4164fd6a1d7SAndreas Gohr if (!is_writable($target)) throw new Exception('noperms'); 4174fd6a1d7SAndreas Gohr return; 4184fd6a1d7SAndreas Gohr } 4194fd6a1d7SAndreas Gohr 4204fd6a1d7SAndreas Gohr // new installs 4214fd6a1d7SAndreas Gohr $target = dirname($target); 4224fd6a1d7SAndreas Gohr if (!is_writable($target)) { 4234fd6a1d7SAndreas Gohr if ($extension->isTemplate()) throw new Exception('notplperms'); 4244fd6a1d7SAndreas Gohr throw new Exception('nopluginperms'); 4254fd6a1d7SAndreas Gohr } 4264fd6a1d7SAndreas Gohr } 427b2a05b76SAndreas Gohr 42825d28a01SAndreas Gohr /** 429cf2dcf1bSAndreas Gohr * Get a base name from an archive name (we don't trust) 430cf2dcf1bSAndreas Gohr * 431cf2dcf1bSAndreas Gohr * @param string $file 432cf2dcf1bSAndreas Gohr * @return string 433cf2dcf1bSAndreas Gohr */ 434cf2dcf1bSAndreas Gohr protected function fileToBase($file) 435cf2dcf1bSAndreas Gohr { 436cf2dcf1bSAndreas Gohr $base = PhpString::basename($file); 437cf2dcf1bSAndreas Gohr $base = preg_replace('/\.(tar\.gz|tar\.bz|tar\.bz2|tar|tgz|tbz|zip)$/', '', $base); 438cf2dcf1bSAndreas Gohr return preg_replace('/\W+/', '', $base); 439cf2dcf1bSAndreas Gohr } 440cf2dcf1bSAndreas Gohr 441cf2dcf1bSAndreas Gohr /** 442cf2dcf1bSAndreas Gohr * Returns a temporary directory 443cf2dcf1bSAndreas Gohr * 444cf2dcf1bSAndreas Gohr * The directory is registered for cleanup when the class is destroyed 445cf2dcf1bSAndreas Gohr * 446cf2dcf1bSAndreas Gohr * @return string 447cf2dcf1bSAndreas Gohr * @throws Exception 448cf2dcf1bSAndreas Gohr */ 449cf2dcf1bSAndreas Gohr protected function mkTmpDir() 450cf2dcf1bSAndreas Gohr { 451cf2dcf1bSAndreas Gohr try { 452cf2dcf1bSAndreas Gohr $dir = io_mktmpdir(); 453cf2dcf1bSAndreas Gohr } catch (\Exception $e) { 454cf2dcf1bSAndreas Gohr throw new Exception('error_dircreate', [], $e); 455cf2dcf1bSAndreas Gohr } 456cf2dcf1bSAndreas Gohr if (!$dir) throw new Exception('error_dircreate'); 457cf2dcf1bSAndreas Gohr $this->temporary[] = $dir; 458cf2dcf1bSAndreas Gohr return $dir; 459cf2dcf1bSAndreas Gohr } 460cf2dcf1bSAndreas Gohr 461cf2dcf1bSAndreas Gohr /** 462cf2dcf1bSAndreas Gohr * Find all extensions in a given directory 463cf2dcf1bSAndreas Gohr * 464cf2dcf1bSAndreas Gohr * This allows us to install extensions from archives that contain multiple extensions and 465cf2dcf1bSAndreas Gohr * also caters for the fact that archives may or may not contain subdirectories for the extension(s). 466cf2dcf1bSAndreas Gohr * 467cf2dcf1bSAndreas Gohr * @param string $dir 468cf2dcf1bSAndreas Gohr * @return Extension[] 469cf2dcf1bSAndreas Gohr */ 470cf2dcf1bSAndreas Gohr protected function findExtensions($dir, $base = null) 471cf2dcf1bSAndreas Gohr { 472cf2dcf1bSAndreas Gohr // first check for plugin.info.txt or template.info.txt 473cf2dcf1bSAndreas Gohr $extensions = []; 474cf2dcf1bSAndreas Gohr $iterator = new RecursiveDirectoryIterator($dir); 475cf2dcf1bSAndreas Gohr foreach (new RecursiveIteratorIterator($iterator) as $file) { 476cf2dcf1bSAndreas Gohr if ( 477cf2dcf1bSAndreas Gohr $file->getFilename() === 'plugin.info.txt' || 478cf2dcf1bSAndreas Gohr $file->getFilename() === 'template.info.txt' 479cf2dcf1bSAndreas Gohr ) { 48025d28a01SAndreas Gohr $extensions[] = Extension::createFromDirectory($file->getPath()); 481cf2dcf1bSAndreas Gohr } 482cf2dcf1bSAndreas Gohr } 483cf2dcf1bSAndreas Gohr if ($extensions) return $extensions; 484cf2dcf1bSAndreas Gohr 485cf2dcf1bSAndreas Gohr // still nothing? we assume this to be a single extension that is either 486cf2dcf1bSAndreas Gohr // directly in the given directory or in single subdirectory 487cf2dcf1bSAndreas Gohr $files = glob($dir . '/*'); 488cf2dcf1bSAndreas Gohr if (count($files) === 1 && is_dir($files[0])) { 489cf2dcf1bSAndreas Gohr $dir = $files[0]; 490cf2dcf1bSAndreas Gohr } 4917c184cfcSAndreas Gohr $base ??= PhpString::basename($dir); 492cf2dcf1bSAndreas Gohr return [Extension::createFromDirectory($dir, null, $base)]; 493cf2dcf1bSAndreas Gohr } 494cf2dcf1bSAndreas Gohr 495cf2dcf1bSAndreas Gohr /** 496cf2dcf1bSAndreas Gohr * Extract the given archive to the given target directory 497cf2dcf1bSAndreas Gohr * 498cf2dcf1bSAndreas Gohr * Auto-guesses the archive type 499cf2dcf1bSAndreas Gohr * @throws Exception 500cf2dcf1bSAndreas Gohr */ 501cf2dcf1bSAndreas Gohr protected function extractArchive($archive, $target) 502cf2dcf1bSAndreas Gohr { 503cf2dcf1bSAndreas Gohr $fh = fopen($archive, 'rb'); 504cf2dcf1bSAndreas Gohr if (!$fh) throw new Exception('error_archive_read', [$archive]); 505cf2dcf1bSAndreas Gohr $magic = fread($fh, 5); 506cf2dcf1bSAndreas Gohr fclose($fh); 507cf2dcf1bSAndreas Gohr 508cf2dcf1bSAndreas Gohr if (strpos($magic, "\x50\x4b\x03\x04") === 0) { 509cf2dcf1bSAndreas Gohr $archiver = new Zip(); 510cf2dcf1bSAndreas Gohr } else { 511cf2dcf1bSAndreas Gohr $archiver = new Tar(); 512cf2dcf1bSAndreas Gohr } 513cf2dcf1bSAndreas Gohr try { 514cf2dcf1bSAndreas Gohr $archiver->open($archive); 515cf2dcf1bSAndreas Gohr $archiver->extract($target); 516cf2dcf1bSAndreas Gohr } catch (ArchiveIOException | ArchiveCorruptedException | ArchiveIllegalCompressionException $e) { 517cf2dcf1bSAndreas Gohr throw new Exception('error_archive_extract', [$archive, $e->getMessage()], $e); 518cf2dcf1bSAndreas Gohr } 519cf2dcf1bSAndreas Gohr } 520cf2dcf1bSAndreas Gohr 521cf2dcf1bSAndreas Gohr /** 522cf2dcf1bSAndreas Gohr * Copy with recursive sub-directory support 523cf2dcf1bSAndreas Gohr * 524cf2dcf1bSAndreas Gohr * @param string $src filename path to file 525cf2dcf1bSAndreas Gohr * @param string $dst filename path to file 526cf2dcf1bSAndreas Gohr * @throws Exception 527cf2dcf1bSAndreas Gohr */ 528cf2dcf1bSAndreas Gohr protected function dircopy($src, $dst) 529cf2dcf1bSAndreas Gohr { 530cf2dcf1bSAndreas Gohr global $conf; 531cf2dcf1bSAndreas Gohr 532cf2dcf1bSAndreas Gohr if (is_dir($src)) { 533cf2dcf1bSAndreas Gohr if (!$dh = @opendir($src)) { 534cf2dcf1bSAndreas Gohr throw new Exception('error_copy_read', [$src]); 535cf2dcf1bSAndreas Gohr } 536cf2dcf1bSAndreas Gohr 537cf2dcf1bSAndreas Gohr if (io_mkdir_p($dst)) { 538cf2dcf1bSAndreas Gohr while (false !== ($f = readdir($dh))) { 539cf2dcf1bSAndreas Gohr if ($f == '..' || $f == '.') continue; 540cf2dcf1bSAndreas Gohr $this->dircopy("$src/$f", "$dst/$f"); 541cf2dcf1bSAndreas Gohr } 542cf2dcf1bSAndreas Gohr } else { 543cf2dcf1bSAndreas Gohr throw new Exception('error_copy_mkdir', [$dst]); 544cf2dcf1bSAndreas Gohr } 545cf2dcf1bSAndreas Gohr 546cf2dcf1bSAndreas Gohr closedir($dh); 547cf2dcf1bSAndreas Gohr } else { 548cf2dcf1bSAndreas Gohr $existed = file_exists($dst); 549cf2dcf1bSAndreas Gohr 550cf2dcf1bSAndreas Gohr if (!@copy($src, $dst)) { 551cf2dcf1bSAndreas Gohr throw new Exception('error_copy_copy', [$src, $dst]); 552cf2dcf1bSAndreas Gohr } 553cf2dcf1bSAndreas Gohr if (!$existed && $conf['fperm']) chmod($dst, $conf['fperm']); 554cf2dcf1bSAndreas Gohr @touch($dst, filemtime($src)); 555cf2dcf1bSAndreas Gohr } 556cf2dcf1bSAndreas Gohr } 557cf2dcf1bSAndreas Gohr 558cf2dcf1bSAndreas Gohr /** 55925d28a01SAndreas Gohr * Reset caches if needed 560cf2dcf1bSAndreas Gohr */ 561cf2dcf1bSAndreas Gohr protected function cleanUp() 562cf2dcf1bSAndreas Gohr { 563cf2dcf1bSAndreas Gohr if ($this->isDirty) { 564cf2dcf1bSAndreas Gohr self::purgeCache(); 565cf2dcf1bSAndreas Gohr $this->isDirty = false; 566cf2dcf1bSAndreas Gohr } 567cf2dcf1bSAndreas Gohr } 568cf2dcf1bSAndreas Gohr} 569