overwrite = $overwrite; } /** * Destructor * * deletes any dangling temporary directories */ public function __destruct() { $this->cleanUp(); } /** * 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) { if ($extension->isInstalled() && !$this->overwrite) { // FIXME remember skipped extensions continue; } $this->dircopy( $extension->getCurrentDir(), $extension->getInstallDir() ); $this->isDirty = true; $extension->updateManagerInfo($this->sourceUrl); $this->removeDeletedFiles($extension); // FIXME remember installed extensions and if it was an update or new install // FIXME queue dependencies for installation } // FIXME process dependency queue $this->cleanUp(); } /** * Uninstall an extension * * @param Extension $extension * @throws Exception */ public function uninstall(Extension $extension) { // FIXME check if dependencies are still needed if($extension->isProtected()) { throw new Exception('error_uninstall_protected', [$extension->getId()]); } if (!io_rmdir($extension->getInstallDir(), true)) { throw new Exception('msg_delete_failed', [$extension->getId()]); } 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); } } 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 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 $base = $base ?? PhpString::basename($dir); $files = glob($dir . '/*'); if (count($files) === 1 && is_dir($files[0])) { $dir = $files[0]; } 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)); } } /** * Clean up all temporary directories and reset caches */ protected function cleanUp() { foreach ($this->temporary as $dir) { io_rmdir($dir, true); } $this->temporary = []; if ($this->isDirty) { self::purgeCache(); $this->isDirty = false; } } }