overwrite = $overwrite; } /** * Destructor * * deletes any dangling temporary directories */ public function __destruct() { foreach ($this->temporary as $dir) { io_rmdir($dir, true); } $this->cleanUp(); } /** * Install an extension by ID * * This will simply call installExtension after constructing an extension from the ID * * The $skipInstalled parameter should only be used when installing dependencies * * @param string $id the extension ID * @param bool $skipInstalled Ignore the overwrite setting and skip installed extensions * @throws Exception */ public function installFromId($id, $skipInstalled = false) { $extension = Extension::createFromId($id); if ($skipInstalled && $extension->isInstalled()) return; $this->installExtension($extension); } /** * Install an extension * * This will simply call installFromUrl() with the URL from the extension * * @param Extension $extension * @throws Exception */ public function installExtension(Extension $extension) { $url = $extension->getDownloadURL(); if (!$url) { throw new Exception('error_nourl', [$extension->getId()]); } $this->installFromUrl($url); } /** * Install extensions from a given URL * * @param string $url the URL to the archive * @param null $base the base directory name to use * @throws Exception */ public function installFromUrl($url, $base = null) { $this->sourceUrl = $url; $archive = $this->downloadArchive($url); $this->installFromArchive( $archive, $base ); } /** * Install extensions from a user upload * * @param string $field name of the upload file * @throws Exception */ public function installFromUpload($field) { $this->sourceUrl = ''; if ($_FILES[$field]['error']) { throw new Exception('msg_upload_failed', [$_FILES[$field]['error']]); } $tmp = $this->mkTmpDir(); if (!move_uploaded_file($_FILES[$field]['tmp_name'], "$tmp/upload.archive")) { throw new Exception('msg_upload_failed', ['move failed']); } $this->installFromArchive( "$tmp/upload.archive", $this->fileToBase($_FILES[$field]['name']), ); } /** * Install extensions from an archive * * The archive is extracted to a temporary directory and then the contained extensions are installed. * This is is the ultimate installation procedure and all other install methods will end up here. * * @param string $archive the path to the archive * @param string $base the base directory name to use * @throws Exception */ public function installFromArchive($archive, $base = null) { if ($base === null) $base = $this->fileToBase($archive); $target = $this->mkTmpDir() . '/' . $base; $this->extractArchive($archive, $target); $extensions = $this->findExtensions($target, $base); foreach ($extensions as $extension) { // check installation status if ($extension->isInstalled()) { if (!$this->overwrite) { $this->processed[$extension->getId()] = self::STATUS_SKIPPED; continue; } $status = self::STATUS_UPDATED; } else { $status = self::STATUS_INSTALLED; } // check PHP requirements self::ensurePhpCompatibility($extension); // install dependencies first foreach ($extension->getDependencyList() as $id) { if (isset($this->processed[$id])) continue; if ($id == $extension->getId()) continue; // avoid circular dependencies $this->installFromId($id, true); } // now install the extension self::ensurePermissions($extension); $this->dircopy( $extension->getCurrentDir(), $extension->getInstallDir() ); $this->isDirty = true; $extension->getManager()->storeUpdate($this->sourceUrl); $this->removeDeletedFiles($extension); $this->processed[$extension->getId()] = $status; } $this->cleanUp(); } /** * Uninstall an extension * * @param Extension $extension * @throws Exception */ public function uninstall(Extension $extension) { if (!$extension->isInstalled()) { throw new Exception('error_notinstalled', [$extension->getId()]); } if ($extension->isProtected()) { throw new Exception('error_uninstall_protected', [$extension->getId()]); } self::ensurePermissions($extension); $dependants = $extension->getDependants(); if($dependants !== []) { throw new Exception('error_uninstall_dependants', [$extension->getId(), implode(', ', $dependants)]); } if (!io_rmdir($extension->getInstallDir(), true)) { throw new Exception('msg_delete_failed', [$extension->getId()]); } self::purgeCache(); $this->processed[$extension->getId()] = self::STATUS_REMOVED; } /** * Enable the extension * * @throws Exception */ public function enable(Extension $extension) { if ($extension->isTemplate()) throw new Exception('notimplemented'); if (!$extension->isInstalled()) throw new Exception('error_notinstalled', [$extension->getId()]); if ($extension->isEnabled()) throw new Exception('error_alreadyenabled', [$extension->getId()]); /* @var PluginController $plugin_controller */ global $plugin_controller; if (!$plugin_controller->enable($extension->getBase())) { throw new Exception('pluginlistsaveerror'); } self::purgeCache(); } /** * Disable the extension * * @throws Exception */ public function disable(Extension $extension) { if ($extension->isTemplate()) throw new Exception('notimplemented'); if (!$extension->isInstalled()) throw new Exception('error_notinstalled', [$extension->getId()]); if (!$extension->isEnabled()) throw new Exception('error_alreadydisabled', [$extension->getId()]); if ($extension->isProtected()) throw new Exception('error_disable_protected', [$extension->getId()]); $dependants = $extension->getDependants(); if($dependants !== []) { throw new Exception('error_disable_dependants', [$extension->getId(), implode(', ', $dependants)]); } /* @var PluginController $plugin_controller */ global $plugin_controller; if (!$plugin_controller->disable($extension->getBase())) { throw new Exception('pluginlistsaveerror'); } self::purgeCache(); } /** * Download an archive to a protected path * * @param string $url The url to get the archive from * @return string The path where the archive was saved * @throws Exception */ public function downloadArchive($url) { // check the url if (!preg_match('/https?:\/\//i', $url)) { throw new Exception('error_badurl'); } // try to get the file from the path (used as plugin name fallback) $file = parse_url($url, PHP_URL_PATH); $file = $file ? PhpString::basename($file) : md5($url); // download $http = new DokuHTTPClient(); $http->max_bodysize = 0; $http->timeout = 25; //max. 25 sec $http->keep_alive = false; // we do single ops here, no need for keep-alive $http->agent = 'DokuWiki HTTP Client (Extension Manager)'; $data = $http->get($url); if ($data === false) throw new Exception('error_download', [$url, $http->error, $http->status]); // get filename from headers if ( preg_match( '/attachment;\s*filename\s*=\s*"([^"]*)"/i', (string)($http->resp_headers['content-disposition'] ?? ''), $match ) ) { $file = PhpString::basename($match[1]); } // clean up filename $file = $this->fileToBase($file); // create tmp directory for download $tmp = $this->mkTmpDir(); // save the file if (@file_put_contents("$tmp/$file", $data) === false) { throw new Exception('error_save'); } return "$tmp/$file"; } /** * Delete outdated files */ public function removeDeletedFiles(Extension $extension) { $extensiondir = $extension->getInstallDir(); $definitionfile = $extensiondir . '/deleted.files'; if (!file_exists($definitionfile)) return; $list = file($definitionfile); foreach ($list as $line) { $line = trim(preg_replace('/#.*$/', '', $line)); $line = str_replace('..', '', $line); // do not run out of the extension directory if (!$line) continue; $file = $extensiondir . '/' . $line; if (!file_exists($file)) continue; io_rmdir($file, true); } } /** * Purge all caches */ public static function purgeCache() { // expire dokuwiki caches // touching local.php expires wiki page, JS and CSS caches global $config_cascade; @touch(reset($config_cascade['main']['local'])); if (function_exists('opcache_reset')) { opcache_reset(); } } /** * Get the list of processed extensions and their status during an installation run * * @return array id => status */ public function getProcessed() { return $this->processed; } /** * Ensure that the given extension is compatible with the current PHP version * * Throws an exception if the extension is not compatible * * @param Extension $extension * @throws Exception */ public static function ensurePhpCompatibility(Extension $extension) { $min = $extension->getMinimumPHPVersion(); if ($min && version_compare(PHP_VERSION, $min, '<')) { throw new Exception('error_minphp', [$extension->getId(), $min, PHP_VERSION]); } $max = $extension->getMaximumPHPVersion(); if ($max && version_compare(PHP_VERSION, $max, '>')) { throw new Exception('error_maxphp', [$extension->getId(), $max, PHP_VERSION]); } } /** * Ensure the file permissions are correct before attempting to install * * @throws Exception if the permissions are not correct */ public static function ensurePermissions(Extension $extension) { $target = $extension->getInstallDir(); // updates if (file_exists($target)) { if (!is_writable($target)) throw new Exception('noperms'); return; } // new installs $target = dirname($target); if (!is_writable($target)) { if ($extension->isTemplate()) throw new Exception('notplperms'); throw new Exception('nopluginperms'); } } /** * Get a base name from an archive name (we don't trust) * * @param string $file * @return string */ protected function fileToBase($file) { $base = PhpString::basename($file); $base = preg_replace('/\.(tar\.gz|tar\.bz|tar\.bz2|tar|tgz|tbz|zip)$/', '', $base); return preg_replace('/\W+/', '', $base); } /** * Returns a temporary directory * * The directory is registered for cleanup when the class is destroyed * * @return string * @throws Exception */ protected function mkTmpDir() { try { $dir = io_mktmpdir(); } catch (\Exception $e) { throw new Exception('error_dircreate', [], $e); } if (!$dir) throw new Exception('error_dircreate'); $this->temporary[] = $dir; return $dir; } /** * Find all extensions in a given directory * * This allows us to install extensions from archives that contain multiple extensions and * also caters for the fact that archives may or may not contain subdirectories for the extension(s). * * @param string $dir * @return Extension[] */ protected function findExtensions($dir, $base = null) { // first check for plugin.info.txt or template.info.txt $extensions = []; $iterator = new RecursiveDirectoryIterator($dir); foreach (new RecursiveIteratorIterator($iterator) as $file) { if ( $file->getFilename() === 'plugin.info.txt' || $file->getFilename() === 'template.info.txt' ) { $extensions[] = Extension::createFromDirectory($file->getPath()); } } if ($extensions) return $extensions; // still nothing? we assume this to be a single extension that is either // directly in the given directory or in single subdirectory $files = glob($dir . '/*'); if (count($files) === 1 && is_dir($files[0])) { $dir = $files[0]; } $base ??= PhpString::basename($dir); return [Extension::createFromDirectory($dir, null, $base)]; } /** * Extract the given archive to the given target directory * * Auto-guesses the archive type * @throws Exception */ protected function extractArchive($archive, $target) { $fh = fopen($archive, 'rb'); if (!$fh) throw new Exception('error_archive_read', [$archive]); $magic = fread($fh, 5); fclose($fh); if (strpos($magic, "\x50\x4b\x03\x04") === 0) { $archiver = new Zip(); } else { $archiver = new Tar(); } try { $archiver->open($archive); $archiver->extract($target); } catch (ArchiveIOException | ArchiveCorruptedException | ArchiveIllegalCompressionException $e) { throw new Exception('error_archive_extract', [$archive, $e->getMessage()], $e); } } /** * Copy with recursive sub-directory support * * @param string $src filename path to file * @param string $dst filename path to file * @throws Exception */ protected function dircopy($src, $dst) { global $conf; if (is_dir($src)) { if (!$dh = @opendir($src)) { throw new Exception('error_copy_read', [$src]); } if (io_mkdir_p($dst)) { while (false !== ($f = readdir($dh))) { if ($f == '..' || $f == '.') continue; $this->dircopy("$src/$f", "$dst/$f"); } } else { throw new Exception('error_copy_mkdir', [$dst]); } closedir($dh); } else { $existed = file_exists($dst); if (!@copy($src, $dst)) { throw new Exception('error_copy_copy', [$src, $dst]); } if (!$existed && $conf['fperm']) chmod($dst, $conf['fperm']); @touch($dst, filemtime($src)); } } /** * Reset caches if needed */ protected function cleanUp() { if ($this->isDirty) { self::purgeCache(); $this->isDirty = false; } } }