aboutsummaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
-rw-r--r--lib/plugins/extension/Exception.php36
-rw-r--r--lib/plugins/extension/Extension.php542
-rw-r--r--lib/plugins/extension/Installer.php384
-rw-r--r--lib/plugins/extension/Repository.php298
-rw-r--r--lib/plugins/extension/_test/ExtensionTest.php24
-rw-r--r--lib/plugins/extension/lang/en/lang.php13
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';