diff options
-rw-r--r-- | lib/plugins/extension/Exception.php | 36 | ||||
-rw-r--r-- | lib/plugins/extension/Extension.php | 542 | ||||
-rw-r--r-- | lib/plugins/extension/Installer.php | 384 | ||||
-rw-r--r-- | lib/plugins/extension/Repository.php | 298 | ||||
-rw-r--r-- | lib/plugins/extension/_test/ExtensionTest.php | 24 | ||||
-rw-r--r-- | lib/plugins/extension/lang/en/lang.php | 13 |
6 files changed, 1294 insertions, 3 deletions
diff --git a/lib/plugins/extension/Exception.php b/lib/plugins/extension/Exception.php new file mode 100644 index 000000000..e7b5f1f0d --- /dev/null +++ b/lib/plugins/extension/Exception.php @@ -0,0 +1,36 @@ +<?php + +namespace dokuwiki\plugin\extension; + +use Throwable; + +/** + * Implements translatable exception messages + */ +class Exception extends \Exception +{ + /** + * @param string $message The error message or language string + * @param array $context array of sprintf variables to be replaced in the message + * @param Throwable|null $previous Previous exception + */ + public function __construct($message = "", $context = [], Throwable $previous = null) + { + // try to translate the message + $helper = plugin_load('helper', 'extension'); + $newmessage = $helper->getLang($message); + if ($newmessage === '') { + $newmessage = $message; + } else { + // add original language string so we still recognize it when reported by foreign users + $newmessage .= ' [' . $message . ']'; + } + + if ($context) { + $newmessage = vsprintf($newmessage, $context); + } + + parent::__construct($newmessage, 0, $previous); + } + +} diff --git a/lib/plugins/extension/Extension.php b/lib/plugins/extension/Extension.php new file mode 100644 index 000000000..d8be00e01 --- /dev/null +++ b/lib/plugins/extension/Extension.php @@ -0,0 +1,542 @@ +<?php + +namespace dokuwiki\plugin\extension; + +use dokuwiki\Extension\PluginController; +use dokuwiki\Utf8\PhpString; +use RuntimeException; + +class Extension +{ + const TYPE_PLUGIN = 'plugin'; + const TYPE_TEMPLATE = 'template'; + + /** @var string "plugin"|"template" */ + protected string $type = self::TYPE_PLUGIN; + + /** @var string The base name of this extension */ + protected string $base; + + /** @var string|null The current location of this extension */ + protected ?string $currentDir; + + /** @var array The local info array of the extension */ + protected array $localInfo = []; + + /** @var array The remote info array of the extension */ + protected array $remoteInfo = []; + + /** @var array The manager info array of the extension */ + protected array $managerInfo = []; + + // region Constructors + + /** + * The main constructor is private to force the use of the factory methods + */ + protected function __construct() + { + } + + /** + * Initializes an extension from a directory + * + * The given directory might be the one where the extension has already been installed to + * or it might be the extracted source in some temporary directory. + * + * @param string $dir Where the extension code is currently located + * @param string|null $type TYPE_PLUGIN|TYPE_TEMPLATE, null for auto-detection + * @param string $base The base name of the extension, null for auto-detection + * @return Extension + */ + public static function createFromDirectory($dir, $type = null, $base = null) + { + $extension = new self(); + $extension->initFromDirectory($dir, $type, $base); + return $extension; + } + + protected function initFromDirectory($dir, $type = null, $base = null) + { + if (!is_dir($dir)) throw new RuntimeException('Directory not found: ' . $dir); + $this->currentDir = realpath($dir); + + if ($type === null || $type === self::TYPE_TEMPLATE) { + if ( + file_exists($dir . '/template.info.php') || + file_exists($dir . '/style.ini') || + file_exists($dir . '/main.php') || + file_exists($dir . '/detail.php') || + file_exists($dir . '/mediamanager.php') + ) { + $this->type = self::TYPE_TEMPLATE; + } + } else { + $this->type = self::TYPE_PLUGIN; + } + + $this->readLocalInfo(); + + if ($base !== null) { + $this->base = $base; + } elseif (isset($this->localInfo['base'])) { + $this->base = $this->localInfo['base']; + } else { + $this->base = basename($dir); + } + } + + /** + * Initializes an extension from remote data + * + * @param array $data The data as returned by the repository api + * @return Extension + */ + public static function createFromRemoteData($data) + { + $extension = new self(); + $extension->initFromRemoteData($data); + return $extension; + } + + protected function initFromRemoteData($data) + { + if (!isset($data['plugin'])) throw new RuntimeException('Invalid remote data'); + + [$type, $base] = sexplode(':', $data['plugin'], 2); + if ($base === null) { + $base = $type; + $type = self::TYPE_PLUGIN; + } else { + $type = self::TYPE_TEMPLATE; + } + + $this->remoteInfo = $data; + $this->type = $type; + $this->base = $base; + + if ($this->isInstalled()) { + $this->currentDir = $this->getInstallDir(); + $this->readLocalInfo(); + } + } + + // endregion + + // region Getters + + /** + * @return string The extension id (same as base but prefixed with "template:" for templates) + */ + public function getId() + { + if ($this->type === self::TYPE_TEMPLATE) { + return self::TYPE_TEMPLATE . ':' . $this->base; + } + return $this->base; + } + + /** + * Get the base name of this extension + * + * @return string + */ + public function getBase() + { + return $this->base; + } + + /** + * Get the type of the extension + * + * @return string "plugin"|"template" + */ + public function getType() + { + return $this->type; + } + + /** + * The current directory of the extension + * + * @return string|null + */ + public function getCurrentDir() + { + // recheck that the current currentDir is still valid + if ($this->currentDir && !is_dir($this->currentDir)) { + $this->currentDir = null; + } + + // if the extension is installed, then the currentDir is the install dir! + if (!$this->currentDir && $this->isInstalled()) { + $this->currentDir = $this->getInstallDir(); + } + + return $this->currentDir; + } + + /** + * Get the directory where this extension should be installed in + * + * Note: this does not mean that the extension is actually installed there + * + * @return string + */ + public function getInstallDir() + { + if ($this->isTemplate()) { + $dir = dirname(tpl_incdir()) . $this->base; + } else { + $dir = DOKU_PLUGIN . $this->base; + } + + return realpath($dir); + } + + + /** + * Get the display name of the extension + * + * @return string + */ + public function getDisplayName() + { + return $this->getTag('name', PhpString::ucwords($this->getBase() . ' ' . $this->getType())); + } + + /** + * Get the author name of the extension + * + * @return string Returns an empty string if the author info is missing + */ + public function getAuthor() + { + return $this->getTag('author'); + } + + /** + * Get the email of the author of the extension if there is any + * + * @return string Returns an empty string if the email info is missing + */ + public function getEmail() + { + // email is only in the local data + return $this->localInfo['email'] ?? ''; + } + + /** + * Get the email id, i.e. the md5sum of the email + * + * @return string Empty string if no email is available + */ + public function getEmailID() + { + if (!empty($this->remoteInfo['emailid'])) return $this->remoteInfo['emailid']; + if (!empty($this->localInfo['email'])) return md5($this->localInfo['email']); + return ''; + } + + /** + * Get the description of the extension + * + * @return string Empty string if no description is available + */ + public function getDescription() + { + return $this->getTag(['desc', 'description']); + } + + /** + * Get the URL of the extension, usually a page on dokuwiki.org + * + * @return string + */ + public function getURL() + { + return $this->getTag( + 'url', + 'https://www.dokuwiki.org/' . + ($this->isTemplate() ? 'template' : 'plugin') . ':' . $this->getBase() + ); + } + + /** + * Is this extension a template? + * + * @return bool false if it is a plugin + */ + public function isTemplate() + { + return $this->type === self::TYPE_TEMPLATE; + } + + /** + * Is the extension installed locally? + * + * @return bool + */ + public function isInstalled() + { + return is_dir($this->getInstallDir()); + } + + /** + * Is the extension under git control? + * + * @return bool + */ + public function isGitControlled() + { + if (!$this->isInstalled()) return false; + return file_exists($this->getInstallDir() . '/.git'); + } + + /** + * If the extension is bundled + * + * @return bool If the extension is bundled + */ + public function isBundled() + { + $this->loadRemoteInfo(); + return $this->remoteInfo['bundled'] ?? in_array( + $this->getId(), + [ + 'authad', + 'authldap', + 'authpdo', + 'authplain', + 'acl', + 'config', + 'extension', + 'info', + 'popularity', + 'revert', + 'safefnrecode', + 'styling', + 'testing', + 'usermanager', + 'logviewer', + 'template:dokuwiki' + ] + ); + } + + /** + * Is the extension protected against any modification (disable/uninstall) + * + * @return bool if the extension is protected + */ + public function isProtected() + { + // never allow deinstalling the current auth plugin: + global $conf; + if ($this->getId() == $conf['authtype']) return true; + + // FIXME disallow current template to be uninstalled + + /** @var PluginController $plugin_controller */ + global $plugin_controller; + $cascade = $plugin_controller->getCascade(); + return ($cascade['protected'][$this->getId()] ?? false); + } + + /** + * Is the extension installed in the correct directory? + * + * @return bool + */ + public function isInWrongFolder() + { + return $this->getInstallDir() != $this->currentDir; + } + + /** + * Is the extension enabled? + * + * @return bool + */ + public function isEnabled() + { + global $conf; + if ($this->isTemplate()) { + return ($conf['template'] == $this->getBase()); + } + + /* @var PluginController $plugin_controller */ + global $plugin_controller; + return $plugin_controller->isEnabled($this->base); + } + + // endregion + + // region Actions + + /** + * Install or update the extension + * + * @throws Exception + */ + public function installOrUpdate() + { + $installer = new Installer(true); + $installer->installFromUrl( + $this->getURL(), + $this->getBase(), + ); + } + + /** + * Uninstall the extension + * @throws Exception + */ + public function uninstall() + { + $installer = new Installer(true); + $installer->uninstall($this); + } + + /** + * Enable the extension + * @todo I'm unsure if this code should be here or part of Installer + * @throws Exception + */ + public function enable() + { + if ($this->isTemplate()) throw new Exception('notimplemented'); + if (!$this->isInstalled()) throw new Exception('notinstalled'); + if ($this->isEnabled()) throw new Exception('alreadyenabled'); + + /* @var PluginController $plugin_controller */ + global $plugin_controller; + if (!$plugin_controller->enable($this->base)) { + throw new Exception('pluginlistsaveerror'); + } + Installer::purgeCache(); + } + + /** + * Disable the extension + * @todo I'm unsure if this code should be here or part of Installer + * @throws Exception + */ + public function disable() + { + if ($this->isTemplate()) throw new Exception('notimplemented'); + if (!$this->isInstalled()) throw new Exception('notinstalled'); + if (!$this->isEnabled()) throw new Exception('alreadydisabled'); + if ($this->isProtected()) throw new Exception('error_disable_protected'); + + /* @var PluginController $plugin_controller */ + global $plugin_controller; + if (!$plugin_controller->disable($this->base)) { + throw new Exception('pluginlistsaveerror'); + } + Installer::purgeCache(); + } + + // endregion + + // region Meta Data Management + + /** + * This updates the timestamp and URL in the manager.dat file + * + * It is called by Installer when installing or updating an extension + * + * @param $url + */ + public function updateManagerInfo($url) + { + $this->managerInfo['downloadurl'] = $url; + if (isset($this->managerInfo['installed'])) { + // it's an update + $this->managerInfo['updated'] = date('r'); + } else { + // it's a new install + $this->managerInfo['installed'] = date('r'); + } + + $managerpath = $this->getInstallDir() . '/manager.dat'; + $data = ''; + foreach ($this->managerInfo as $k => $v) { + $data .= $k . '=' . $v . DOKU_LF; + } + io_saveFile($managerpath, $data); + } + + /** + * Reads the manager.dat file and fills the managerInfo array + */ + protected function readManagerInfo() + { + if ($this->managerInfo) return; + + $managerpath = $this->getInstallDir() . '/manager.dat'; + if (!is_readable($managerpath)) return; + + $file = (array)@file($managerpath); + foreach ($file as $line) { + [$key, $value] = sexplode('=', $line, 2, ''); + $key = trim($key); + $value = trim($value); + // backwards compatible with old plugin manager + if ($key == 'url') $key = 'downloadurl'; + $this->managerInfo[$key] = $value; + } + } + + /** + * Reads the info file of the extension if available and fills the localInfo array + */ + protected function readLocalInfo() + { + if (!$this->currentDir) return; + $file = $this->currentDir . '/' . $this->type . '.info.txt'; + if (!is_readable($file)) return; + $this->localInfo = confToHash($file, true); + $this->localInfo = array_filter($this->localInfo); // remove all falsy keys + } + + /** + * Fetches the remote info from the repository + * + * This ignores any errors coming from the repository and just sets the remoteInfo to an empty array in that case + */ + protected function loadRemoteInfo() + { + if ($this->remoteInfo) return; + $remote = Repository::getInstance(); + try { + $this->remoteInfo = (array)$remote->getExtensionData($this->getId()); + } catch (Exception $e) { + $this->remoteInfo = []; + } + } + + /** + * Read information from either local or remote info + * + * Always prefers local info over remote info + * + * @param string|string[] $tag one or multiple keys to check + * @param mixed $default + * @return mixed + */ + protected function getTag($tag, $default = '') + { + foreach ((array)$tag as $t) { + if (isset($this->localInfo[$t])) return $this->localInfo[$t]; + } + $this->loadRemoteInfo(); + foreach ((array)$tag as $t) { + if (isset($this->remoteInfo[$t])) return $this->remoteInfo[$t]; + } + + return $default; + } + + // endregion +} diff --git a/lib/plugins/extension/Installer.php b/lib/plugins/extension/Installer.php new file mode 100644 index 000000000..edc18d201 --- /dev/null +++ b/lib/plugins/extension/Installer.php @@ -0,0 +1,384 @@ +<?php + +namespace dokuwiki\plugin\extension; + +use dokuwiki\HTTP\DokuHTTPClient; +use dokuwiki\Utf8\PhpString; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use splitbrain\PHPArchive\ArchiveCorruptedException; +use splitbrain\PHPArchive\ArchiveIllegalCompressionException; +use splitbrain\PHPArchive\ArchiveIOException; +use splitbrain\PHPArchive\Tar; +use splitbrain\PHPArchive\Zip; + +/** + * Install and deinstall extensions + * + * This manages all the file operations and downloads needed to install an extension. + */ +class Installer +{ + /** @var string[] a list of temporary directories used during this installation */ + protected array $temporary = []; + + /** @var bool if changes have been made that require a cache purge */ + protected $isDirty = false; + + /** @var bool Replace existing files? */ + protected $overwrite = false; + + /** @var string The last used URL to install an extension */ + protected $sourceUrl = ''; + + /** + * Initialize a new extension installer + * + * @param bool $overwrite + */ + public function __construct($overwrite = false) + { + $this->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; + } + } +} diff --git a/lib/plugins/extension/Repository.php b/lib/plugins/extension/Repository.php new file mode 100644 index 000000000..efabc68cf --- /dev/null +++ b/lib/plugins/extension/Repository.php @@ -0,0 +1,298 @@ +<?php + +namespace dokuwiki\plugin\extension; + +use dokuwiki\Cache\Cache; +use dokuwiki\plugin\upgrade\HTTP\DokuHTTPClient; +use JsonException; + +class Repository +{ + public const EXTENSION_REPOSITORY_API = 'https://www.dokuwiki.org/lib/plugins/pluginrepo/api.php'; + + protected const CACHE_PREFIX = '##extension_manager##'; + protected const CACHE_SUFFIX = '.repo'; + protected const CACHE_TIME = 3600 * 24; + + protected static $instance; + protected $hasAccess; + + /** + * + */ + protected function __construct() + { + } + + /** + * @return Repository + */ + public static function getInstance() + { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } + + /** + * Check if access to the repository is possible + * + * On the first call this will throw an exception if access is not possible. On subsequent calls + * it will return the cached result. Thus it is recommended to call this method once when instantiating + * the repository for the first time and handle the exception there. Subsequent calls can then be used + * to access cached data. + * + * @return bool + * @throws Exception + */ + public function checkAccess() + { + if ($this->hasAccess !== null) { + return $this->hasAccess; // we already checked + } + + // check for SSL support + if (!in_array('ssl', stream_get_transports())) { + throw new Exception('nossl'); + } + + // ping the API + $httpclient = new DokuHTTPClient(); + $httpclient->timeout = 5; + $data = $httpclient->get(self::EXTENSION_REPOSITORY_API . '?cmd=ping'); + if ($data === false) { + $this->hasAccess = false; + throw new Exception('repo_error'); + } elseif ($data !== '1') { + $this->hasAccess = false; + throw new Exception('repo_badresponse'); + } else { + $this->hasAccess = true; + } + return $this->hasAccess; + } + + /** + * Fetch the data for multiple extensions from the repository + * + * @param string[] $ids A list of extension ids + * @throws Exception + */ + protected function fetchExtensions($ids) + { + if (!$this->checkAccess()) return; + + $httpclient = new DokuHTTPClient(); + $data = [ + 'fmt' => 'json', + 'ext' => $ids + ]; + + $response = $httpclient->post(self::EXTENSION_REPOSITORY_API, $data); + if ($response === false) { + $this->hasAccess = false; + throw new Exception('repo_error'); + } + + try { + $extensions = json_decode($response, true, 512, JSON_THROW_ON_ERROR); + foreach ($extensions as $extension) { + $this->storeCache($extension['plugin'], $extension); + } + } catch (JsonExceptionAlias $e) { + $this->hasAccess = false; + throw new Exception('repo_badresponse', 0, $e); + } + } + + /** + * This creates a list of Extension objects from the given list of ids + * + * The extensions are initialized by fetching their data from the cache or the repository. + * This is the recommended way to initialize a whole bunch of extensions at once as it will only do + * a single API request for all extensions that are not in the cache. + * + * Extensions that are not found in the cache or the repository will be initialized as null. + * + * @param string[] $ids + * @return (Extension|null)[] [id => Extension|null, ...] + * @throws Exception + */ + public function initExtensions($ids) + { + $result = []; + $toload = []; + + // first get all that are cached + foreach ($ids as $id) { + $data = $this->retrieveCache($id); + if ($data === null) { + $toload[] = $id; + } else { + $result[$id] = Extension::createFromRemoteData($data); + } + } + + // then fetch the rest at once + if ($toload) { + $this->fetchExtensions($toload); + foreach ($toload as $id) { + $data = $this->retrieveCache($id); + if ($data === null) { + $result[$id] = null; + } else { + $result[$id] = Extension::createFromRemoteData($data); + } + } + } + + return $result; + } + + /** + * Initialize a new Extension object from remote data for the given id + * + * @param string $id + * @return Extension|null + * @throws Exception + */ + public function initExtension($id) + { + $result = $this->initExtensions([$id]); + return $result[$id]; + } + + /** + * Get the pure API data for a single extension + * + * Used when lazy loading remote data in Extension + * + * @param string $id + * @return array|null + * @throws Exception + */ + public function getExtensionData($id) + { + $data = $this->retrieveCache($id); + if ($data === null) { + $this->fetchExtensions([$id]); + $data = $this->retrieveCache($id); + } + return $data; + } + + /** + * Search for extensions using the given query string + * + * @param string $q the query string + * @return Extension[] a list of matching extensions + * @throws Exception + */ + public function searchExtensions($q) + { + if (!$this->checkAccess()) return []; + + $query = $this->parseQuery($q); + $query['fmt'] = 'json'; + + $httpclient = new DokuHTTPClient(); + $response = $httpclient->post(self::EXTENSION_REPOSITORY_API, $query); + if ($response === false) { + $this->hasAccess = false; + throw new Exception('repo_error'); + } + + try { + $items = json_decode($response, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + $this->hasAccess = false; + throw new Exception('repo_badresponse', 0, $e); + } + + $results = []; + foreach ($items as $item) { + $this->storeCache($item['plugin'], $item); + $results[] = Extension::createFromRemoteData($item); + } + return $results; + } + + /** + * Parses special queries from the query string + * + * @param string $q + * @return array + */ + protected function parseQuery($q) + { + $parameters = [ + 'tag' => [], + 'mail' => [], + 'type' => [], + 'ext' => [] + ]; + + // extract tags + if (preg_match_all('/(^|\s)(tag:([\S]+))/', $q, $matches, PREG_SET_ORDER)) { + foreach ($matches as $m) { + $q = str_replace($m[2], '', $q); + $parameters['tag'][] = $m[3]; + } + } + // extract author ids + if (preg_match_all('/(^|\s)(authorid:([\S]+))/', $q, $matches, PREG_SET_ORDER)) { + foreach ($matches as $m) { + $q = str_replace($m[2], '', $q); + $parameters['mail'][] = $m[3]; + } + } + // extract extensions + if (preg_match_all('/(^|\s)(ext:([\S]+))/', $q, $matches, PREG_SET_ORDER)) { + foreach ($matches as $m) { + $q = str_replace($m[2], '', $q); + $parameters['ext'][] = $m[3]; + } + } + // extract types + if (preg_match_all('/(^|\s)(type:([\S]+))/', $q, $matches, PREG_SET_ORDER)) { + foreach ($matches as $m) { + $q = str_replace($m[2], '', $q); + $parameters['type'][] = $m[3]; + } + } + + // FIXME make integer from type value + + $parameters['q'] = trim($q); + return $parameters; + } + + + /** + * Store the data for a single extension in the cache + * + * @param string $id + * @param array $data + */ + protected function storeCache($id, $data) + { + $cache = new Cache(self::CACHE_PREFIX . $id, self::CACHE_SUFFIX); + $cache->storeCache(serialize($data)); + } + + /** + * Retrieve the data for a single extension from the cache + * + * @param string $id + * @return array|null the data or null if not in cache + */ + protected function retrieveCache($id) + { + $cache = new Cache(self::CACHE_PREFIX . $id, self::CACHE_SUFFIX); + if ($cache->useCache(['age' => self::CACHE_TIME])) { + return unserialize($cache->retrieveCache(false)); + } + return null; + } +} diff --git a/lib/plugins/extension/_test/ExtensionTest.php b/lib/plugins/extension/_test/ExtensionTest.php new file mode 100644 index 000000000..d93f82e41 --- /dev/null +++ b/lib/plugins/extension/_test/ExtensionTest.php @@ -0,0 +1,24 @@ +<?php + +namespace dokuwiki\plugin\extension\test; + +use dokuwiki\plugin\extension\Extension; +use DokuWikiTest; + +/** + * Tests for the extension plugin + * + * @group plugin_extension + * @group plugins + */ +class ExtensionTest extends DokuWikiTest +{ + + public function testSomething() + { + $extension = Extension::createFromDirectory(__DIR__.'/../'); + + $this->assertFalse($extension->isTemplate()); + $this->assertEquals('extension', $extension->getBase()); + } +} diff --git a/lib/plugins/extension/lang/en/lang.php b/lib/plugins/extension/lang/en/lang.php index a0d249647..49a8698a9 100644 --- a/lib/plugins/extension/lang/en/lang.php +++ b/lib/plugins/extension/lang/en/lang.php @@ -75,7 +75,7 @@ $lang['msg_template_install_success'] = 'Template %s installed successfully'; $lang['msg_template_update_success'] = 'Template %s updated successfully'; $lang['msg_plugin_install_success'] = 'Plugin %s installed successfully'; $lang['msg_plugin_update_success'] = 'Plugin %s updated successfully'; -$lang['msg_upload_failed'] = 'Uploading the file failed'; +$lang['msg_upload_failed'] = 'Uploading the file failed: %s'; $lang['msg_nooverwrite'] = 'Extension %s already exists so it is not being overwritten; to overwrite, tick the overwrite option'; $lang['missing_dependency'] = '<strong>Missing or disabled dependency:</strong> %s'; @@ -88,10 +88,17 @@ $lang['url_change'] = '<strong>URL changed:</strong> Download $lang['error_badurl'] = 'URLs should start with http or https'; $lang['error_dircreate'] = 'Unable to create temporary folder to receive download'; -$lang['error_download'] = 'Unable to download the file: %s'; +$lang['error_download'] = 'Unable to download the file: %s %s %s'; $lang['error_decompress'] = 'Unable to decompress the downloaded file. This maybe as a result of a bad download, in which case you should try again; or the compression format may be unknown, in which case you will need to download and install manually.'; $lang['error_findfolder'] = 'Unable to identify extension directory, you need to download and install manually'; -$lang['error_copy'] = 'There was a file copy error while attempting to install files for directory <em>%s</em>: the disk could be full or file access permissions may be incorrect. This may have resulted in a partially installed plugin and leave your wiki installation unstable'; +$lang['error_copy'] = 'There was a file copy error while attempting to install files for directory \'%s\': the disk could be full or file access permissions may be incorrect. This may have resulted in a partially installed plugin and leave your wiki installation unstable'; +$lang['error_copy_read'] = 'Could not read directory %s'; +$lang['error_copy_mkdir'] = 'Could not create directory %s'; +$lang['error_copy_copy'] = 'Could not copy %s to %s'; +$lang['error_archive_read'] = 'Could not open archive %s for reading'; +$lang['error_archive_extract'] = 'Could not extract archive %s: %s'; +$lang['error_uninstall_protected'] = 'Extension %s is protected and cannot be uninstalled'; +$lang['error_disable_protected'] = 'Extension %s is protected and cannot be disabled'; $lang['noperms'] = 'Extension directory is not writable'; $lang['notplperms'] = 'Template directory is not writable'; |