summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFrederic Guillot <fred@kanboard.net>2016-05-20 12:51:05 -0400
committerFrederic Guillot <fred@kanboard.net>2016-05-20 12:51:05 -0400
commit8d69c49da595c60dae51c77d48f397ab97fdf318 (patch)
tree7fba4edb18c5c4c161e76828d5847733aca8d27b
parentcbf896e74e666f102f475787202d3402f229a919 (diff)
Manage plugins from the user interface and from the command line
-rw-r--r--ChangeLog1
-rw-r--r--app/Console/BaseCommand.php2
-rw-r--r--app/Console/PluginInstallCommand.php35
-rw-r--r--app/Console/PluginUninstallCommand.php35
-rw-r--r--app/Console/PluginUpgradeCommand.php53
-rw-r--r--app/Controller/PluginController.php93
-rw-r--r--app/Core/Plugin/Installer.php162
-rw-r--r--app/Core/Plugin/Loader.php15
-rw-r--r--app/Core/Plugin/PluginInstallerException.php15
-rw-r--r--app/ServiceProvider/RouteProvider.php2
-rw-r--r--app/Template/board/table_column.php2
-rw-r--r--app/Template/plugin/directory.php65
-rw-r--r--app/Template/plugin/remove.php13
-rw-r--r--app/Template/plugin/show.php22
-rw-r--r--app/constants.php6
-rw-r--r--doc/cli.markdown26
-rw-r--r--doc/config.markdown13
-rw-r--r--doc/index.markdown1
-rw-r--r--doc/plugin-directory.markdown15
-rwxr-xr-xkanboard48
20 files changed, 563 insertions, 61 deletions
diff --git a/ChangeLog b/ChangeLog
index 94da950e..a1113dcc 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -3,6 +3,7 @@ Version 1.0.29 (unreleased)
New features:
+* Manage plugin from the user interface and from the command line
* Added the possibility to convert a subtask to a task
* Added menu entry to add tasks from all project views
* Add tasks in bulk from the board
diff --git a/app/Console/BaseCommand.php b/app/Console/BaseCommand.php
index 4444ceba..ca566266 100644
--- a/app/Console/BaseCommand.php
+++ b/app/Console/BaseCommand.php
@@ -26,6 +26,8 @@ use Symfony\Component\Console\Command\Command;
* @property \Kanboard\Model\UserNotification $userNotification
* @property \Kanboard\Model\UserNotificationFilter $userNotificationFilter
* @property \Kanboard\Model\ProjectUserRole $projectUserRole
+ * @property \Kanboard\Core\Plugin\Loader $pluginLoader
+ * @property \Kanboard\Core\Http\Client $httpClient
* @property \Symfony\Component\EventDispatcher\EventDispatcher $dispatcher
*/
abstract class BaseCommand extends Command
diff --git a/app/Console/PluginInstallCommand.php b/app/Console/PluginInstallCommand.php
new file mode 100644
index 00000000..1c6e14b3
--- /dev/null
+++ b/app/Console/PluginInstallCommand.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Kanboard\Console;
+
+use Kanboard\Core\Plugin\Installer;
+use Kanboard\Core\Plugin\PluginInstallerException;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class PluginInstallCommand extends BaseCommand
+{
+ protected function configure()
+ {
+ $this
+ ->setName('plugin:install')
+ ->setDescription('Install a plugin from a remote Zip archive')
+ ->addArgument('url', InputArgument::REQUIRED, 'Archive URL');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output)
+ {
+ if (!Installer::isConfigured()) {
+ $output->writeln('<error>Kanboard is not configured to install plugins itself</error>');
+ }
+
+ try {
+ $installer = new Installer($this->container);
+ $installer->install($input->getArgument('url'));
+ $output->writeln('<info>Plugin installed successfully</info>');
+ } catch (PluginInstallerException $e) {
+ $output->writeln('<error>'.$e->getMessage().'</error>');
+ }
+ }
+}
diff --git a/app/Console/PluginUninstallCommand.php b/app/Console/PluginUninstallCommand.php
new file mode 100644
index 00000000..c645e03f
--- /dev/null
+++ b/app/Console/PluginUninstallCommand.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Kanboard\Console;
+
+use Kanboard\Core\Plugin\Installer;
+use Kanboard\Core\Plugin\PluginInstallerException;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class PluginUninstallCommand extends BaseCommand
+{
+ protected function configure()
+ {
+ $this
+ ->setName('plugin:uninstall')
+ ->setDescription('Remove a plugin')
+ ->addArgument('pluginId', InputArgument::REQUIRED, 'Plugin directory name');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output)
+ {
+ if (!Installer::isConfigured()) {
+ $output->writeln('<error>Kanboard is not configured to remove plugins itself</error>');
+ }
+
+ try {
+ $installer = new Installer($this->container);
+ $installer->uninstall($input->getArgument('pluginId'));
+ $output->writeln('<info>Plugin removed successfully</info>');
+ } catch (PluginInstallerException $e) {
+ $output->writeln('<error>'.$e->getMessage().'</error>');
+ }
+ }
+}
diff --git a/app/Console/PluginUpgradeCommand.php b/app/Console/PluginUpgradeCommand.php
new file mode 100644
index 00000000..6ec5836d
--- /dev/null
+++ b/app/Console/PluginUpgradeCommand.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Kanboard\Console;
+
+use Kanboard\Core\Plugin\Base as BasePlugin;
+use Kanboard\Core\Plugin\Installer;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class PluginUpgradeCommand extends BaseCommand
+{
+ protected function configure()
+ {
+ $this
+ ->setName('plugin:upgrade')
+ ->setDescription('Update all installed plugins')
+ ;
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output)
+ {
+ if (!Installer::isConfigured()) {
+ $output->writeln('<error>Kanboard is not configured to upgrade plugins itself</error>');
+ }
+
+ $installer = new Installer($this->container);
+ $availablePlugins = $this->httpClient->getJson(PLUGIN_API_URL);
+
+ foreach ($this->pluginLoader->getPlugins() as $installedPlugin) {
+ $pluginDetails = $this->getPluginDetails($availablePlugins, $installedPlugin);
+
+ if ($pluginDetails === null) {
+ $output->writeln('<error>* Plugin not available in the directory: '.$installedPlugin->getPluginName().'</error>');
+ } elseif ($pluginDetails['version'] > $installedPlugin->getPluginVersion()) {
+ $output->writeln('<comment>* Updating plugin: '.$installedPlugin->getPluginName().'</comment>');
+ $installer->update($pluginDetails['download']);
+ } else {
+ $output->writeln('<info>* Plugin up to date: '.$installedPlugin->getPluginName().'</info>');
+ }
+ }
+ }
+
+ protected function getPluginDetails(array $availablePlugins, BasePlugin $installedPlugin)
+ {
+ foreach ($availablePlugins as $availablePlugin) {
+ if ($availablePlugin['title'] === $installedPlugin->getPluginName()) {
+ return $availablePlugin;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/app/Controller/PluginController.php b/app/Controller/PluginController.php
index 8d5628f1..b6f9a33b 100644
--- a/app/Controller/PluginController.php
+++ b/app/Controller/PluginController.php
@@ -2,6 +2,9 @@
namespace Kanboard\Controller;
+use Kanboard\Core\Plugin\Installer;
+use Kanboard\Core\Plugin\PluginInstallerException;
+
/**
* Class PluginController
*
@@ -18,8 +21,9 @@ class PluginController extends BaseController
public function show()
{
$this->response->html($this->helper->layout->plugin('plugin/show', array(
- 'plugins' => $this->pluginLoader->plugins,
+ 'plugins' => $this->pluginLoader->getPlugins(),
'title' => t('Installed Plugins'),
+ 'is_configured' => Installer::isConfigured(),
)));
}
@@ -28,11 +32,94 @@ class PluginController extends BaseController
*/
public function directory()
{
- $plugins = $this->httpClient->getJson(PLUGIN_API_URL);
+ $installedPlugins = array();
+
+ foreach ($this->pluginLoader->getPlugins() as $plugin) {
+ $installedPlugins[$plugin->getPluginName()] = $plugin->getPluginVersion();
+ }
$this->response->html($this->helper->layout->plugin('plugin/directory', array(
- 'plugins' => $plugins,
+ 'installed_plugins' => $installedPlugins,
+ 'available_plugins' => $this->httpClient->getJson(PLUGIN_API_URL),
'title' => t('Plugin Directory'),
+ 'is_configured' => Installer::isConfigured(),
)));
}
+
+ /**
+ * Install plugin from URL
+ *
+ * @throws \Kanboard\Core\Controller\AccessForbiddenException
+ */
+ public function install()
+ {
+ $this->checkCSRFParam();
+ $pluginArchiveUrl = urldecode($this->request->getStringParam('archive_url'));
+
+ try {
+ $installer = new Installer($this->container);
+ $installer->install($pluginArchiveUrl);
+ $this->flash->success(t('Plugin installed successfully.'));
+ } catch (PluginInstallerException $e) {
+ $this->flash->failure($e->getMessage());
+ }
+
+ $this->response->redirect($this->helper->url->to('PluginController', 'show'));
+ }
+
+ /**
+ * Update plugin from URL
+ *
+ * @throws \Kanboard\Core\Controller\AccessForbiddenException
+ */
+ public function update()
+ {
+ $this->checkCSRFParam();
+ $pluginArchiveUrl = urldecode($this->request->getStringParam('archive_url'));
+
+ try {
+ $installer = new Installer($this->container);
+ $installer->update($pluginArchiveUrl);
+ $this->flash->success(t('Plugin updated successfully.'));
+ } catch (PluginInstallerException $e) {
+ $this->flash->failure($e->getMessage());
+ }
+
+ $this->response->redirect($this->helper->url->to('PluginController', 'show'));
+ }
+
+ /**
+ * Confirmation before to remove the plugin
+ */
+ public function confirm()
+ {
+ $pluginId = $this->request->getStringParam('pluginId');
+ $plugins = $this->pluginLoader->getPlugins();
+
+ $this->response->html($this->template->render('plugin/remove', array(
+ 'plugin_id' => $pluginId,
+ 'plugin' => $plugins[$pluginId],
+ )));
+ }
+
+ /**
+ * Remove a plugin
+ *
+ * @throws \Kanboard\Core\Controller\AccessForbiddenException
+ */
+ public function uninstall()
+ {
+ $this->checkCSRFParam();
+ $pluginId = $this->request->getStringParam('pluginId');
+
+ try {
+ $installer = new Installer($this->container);
+ $installer->uninstall($pluginId);
+ $this->flash->success(t('Plugin removed successfully.'));
+ } catch (PluginInstallerException $e) {
+ $this->flash->failure($e->getMessage());
+ }
+
+ $this->response->redirect($this->helper->url->to('PluginController', 'show'));
+ }
}
diff --git a/app/Core/Plugin/Installer.php b/app/Core/Plugin/Installer.php
new file mode 100644
index 00000000..48c4d978
--- /dev/null
+++ b/app/Core/Plugin/Installer.php
@@ -0,0 +1,162 @@
+<?php
+
+namespace Kanboard\Core\Plugin;
+
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+use ZipArchive;
+
+/**
+ * Class Installer
+ *
+ * @package Kanboard\Core\Plugin
+ * @author Frederic Guillot
+ */
+class Installer extends \Kanboard\Core\Base
+{
+ /**
+ * Return true if Kanboard is configured to install plugins
+ *
+ * @static
+ * @access public
+ * @return bool
+ */
+ public static function isConfigured()
+ {
+ return PLUGIN_INSTALLER && is_writable(PLUGINS_DIR) && extension_loaded('zip');
+ }
+
+ /**
+ * Install a plugin
+ *
+ * @access public
+ * @param string $archiveUrl
+ * @throws PluginInstallerException
+ */
+ public function install($archiveUrl)
+ {
+ $zip = $this->downloadPluginArchive($archiveUrl);
+
+ if (! $zip->extractTo(PLUGINS_DIR)) {
+ $this->cleanupArchive($zip);
+ throw new PluginInstallerException(t('Unable to extract plugin archive.'));
+ }
+
+ $this->cleanupArchive($zip);
+ }
+
+ /**
+ * Uninstall a plugin
+ *
+ * @access public
+ * @param string $pluginId
+ * @throws PluginInstallerException
+ */
+ public function uninstall($pluginId)
+ {
+ $pluginFolder = PLUGINS_DIR.DIRECTORY_SEPARATOR.basename($pluginId);
+
+ if (! file_exists($pluginFolder)) {
+ throw new PluginInstallerException(t('Plugin not found.'));
+ }
+
+ if (! is_writable($pluginFolder)) {
+ throw new PluginInstallerException(e('You don\'t have the permission to remove this plugin.'));
+ }
+
+ $this->removeAllDirectories($pluginFolder);
+ }
+
+ /**
+ * Update a plugin
+ *
+ * @access public
+ * @param string $archiveUrl
+ * @throws PluginInstallerException
+ */
+ public function update($archiveUrl)
+ {
+ $zip = $this->downloadPluginArchive($archiveUrl);
+
+ $firstEntry = $zip->statIndex(0);
+ $this->uninstall($firstEntry['name']);
+
+ if (! $zip->extractTo(PLUGINS_DIR)) {
+ $this->cleanupArchive($zip);
+ throw new PluginInstallerException(t('Unable to extract plugin archive.'));
+ }
+
+ $this->cleanupArchive($zip);
+ }
+
+ /**
+ * Download archive from URL
+ *
+ * @access protected
+ * @param string $archiveUrl
+ * @return ZipArchive
+ * @throws PluginInstallerException
+ */
+ protected function downloadPluginArchive($archiveUrl)
+ {
+ $zip = new ZipArchive();
+ $archiveData = $this->httpClient->get($archiveUrl);
+ $archiveFile = tempnam(sys_get_temp_dir(), 'kb_plugin');
+
+ if (empty($archiveData)) {
+ unlink($archiveFile);
+ throw new PluginInstallerException(t('Unable to download plugin archive.'));
+ }
+
+ if (file_put_contents($archiveFile, $archiveData) === false) {
+ unlink($archiveFile);
+ throw new PluginInstallerException(t('Unable to write temporary file for plugin.'));
+ }
+
+ if ($zip->open($archiveFile) !== true) {
+ unlink($archiveFile);
+ throw new PluginInstallerException(t('Unable to open plugin archive.'));
+ }
+
+ if ($zip->numFiles === 0) {
+ unlink($archiveFile);
+ throw new PluginInstallerException(t('There is no file in the plugin archive.'));
+ }
+
+ return $zip;
+ }
+
+ /**
+ * Remove archive file
+ *
+ * @access protected
+ * @param ZipArchive $zip
+ */
+ protected function cleanupArchive(ZipArchive $zip)
+ {
+ unlink($zip->filename);
+ $zip->close();
+ }
+
+ /**
+ * Remove recursively a directory
+ *
+ * @access protected
+ * @param string $directory
+ */
+ protected function removeAllDirectories($directory)
+ {
+ $it = new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS);
+ $files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST);
+
+ foreach ($files as $file) {
+ if ($file->isDir()) {
+ rmdir($file->getRealPath());
+ } else {
+ unlink($file->getRealPath());
+ }
+ }
+
+ rmdir($directory);
+ }
+}
diff --git a/app/Core/Plugin/Loader.php b/app/Core/Plugin/Loader.php
index 400517b7..f2f6add7 100644
--- a/app/Core/Plugin/Loader.php
+++ b/app/Core/Plugin/Loader.php
@@ -18,16 +18,16 @@ class Loader extends \Kanboard\Core\Base
/**
* Plugin instances
*
- * @access public
+ * @access protected
* @var array
*/
- public $plugins = array();
+ protected $plugins = array();
/**
* Get list of loaded plugins
*
* @access public
- * @return array
+ * @return Base[]
*/
public function getPlugins()
{
@@ -52,7 +52,7 @@ class Loader extends \Kanboard\Core\Base
if ($fileInfo->isDir() && substr($fileInfo->getFilename(), 0, 1) !== '.') {
$pluginName = $fileInfo->getFilename();
$this->loadSchema($pluginName);
- $this->initializePlugin($this->loadPlugin($pluginName));
+ $this->initializePlugin($pluginName, $this->loadPlugin($pluginName));
}
}
}
@@ -95,9 +95,10 @@ class Loader extends \Kanboard\Core\Base
* Initialize plugin
*
* @access public
- * @param Base $plugin
+ * @param string $pluginName
+ * @param Base $plugin
*/
- public function initializePlugin(Base $plugin)
+ public function initializePlugin($pluginName, Base $plugin)
{
if (method_exists($plugin, 'onStartup')) {
$this->dispatcher->addListener('app.bootstrap', array($plugin, 'onStartup'));
@@ -107,6 +108,6 @@ class Loader extends \Kanboard\Core\Base
Tool::buildDICHelpers($this->container, $plugin->getHelpers());
$plugin->initialize();
- $this->plugins[] = $plugin;
+ $this->plugins[$pluginName] = $plugin;
}
}
diff --git a/app/Core/Plugin/PluginInstallerException.php b/app/Core/Plugin/PluginInstallerException.php
new file mode 100644
index 00000000..7d356c9b
--- /dev/null
+++ b/app/Core/Plugin/PluginInstallerException.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Kanboard\Core\Plugin;
+
+use Exception;
+
+/**
+ * Class PluginInstallerException
+ *
+ * @package Kanboard\Core\Plugin
+ * @author Frederic Guillot
+ */
+class PluginInstallerException extends Exception
+{
+}
diff --git a/app/ServiceProvider/RouteProvider.php b/app/ServiceProvider/RouteProvider.php
index 9001f176..a7338994 100644
--- a/app/ServiceProvider/RouteProvider.php
+++ b/app/ServiceProvider/RouteProvider.php
@@ -178,7 +178,7 @@ class RouteProvider implements ServiceProviderInterface
// Plugins
$container['route']->addRoute('extensions', 'PluginController', 'show');
- $container['route']->addRoute('extensions/list', 'PluginController', 'directory');
+ $container['route']->addRoute('extensions/directory', 'PluginController', 'directory');
// Doc
$container['route']->addRoute('documentation/:file', 'doc', 'show');
diff --git a/app/Template/board/table_column.php b/app/Template/board/table_column.php
index a356849c..eced52dc 100644
--- a/app/Template/board/table_column.php
+++ b/app/Template/board/table_column.php
@@ -37,7 +37,7 @@
</li>
<?php if ($this->user->hasProjectAccess('TaskCreationController', 'show', $column['project_id'])): ?>
<li>
- <i class="fa fa-align-justify" aria-hidden="true"></i>
+ <i class="fa fa-align-justify fa-fw" aria-hidden="true"></i>
<?= $this->url->link(t('Create tasks in bulk'), 'TaskBulkController', 'show', array('project_id' => $column['project_id'], 'column_id' => $column['id'], 'swimlane_id' => $swimlane['id']), false, 'popover') ?>
</li>
<?php if ($column['nb_tasks'] > 0): ?>
diff --git a/app/Template/plugin/directory.php b/app/Template/plugin/directory.php
index 82b9a441..b6c6734c 100644
--- a/app/Template/plugin/directory.php
+++ b/app/Template/plugin/directory.php
@@ -2,29 +2,54 @@
<h2><?= t('Plugin Directory') ?></h2>
</div>
-<?php if (empty($plugins)): ?>
+<?php if (! $is_configured): ?>
+<p class="alert alert-error">
+ <?= t('Your Kanboard instance is not configured to install plugins from the user interface.') ?>
+</p>
+<?php endif ?>
+
+<?php if (empty($available_plugins)): ?>
<p class="alert"><?= t('There is no plugin available.') ?></p>
<?php else: ?>
- <table class="table-stripped">
+ <?php foreach ($available_plugins as $plugin): ?>
+ <table>
<tr>
- <th class="column-20"><?= t('Name') ?></th>
- <th class="column-20"><?= t('Author') ?></th>
- <th class="column-10"><?= t('Version') ?></th>
- <th><?= t('Description') ?></th>
- <th><?= t('Action') ?></th>
+ <th colspan="3">
+ <a href="<?= $plugin['homepage'] ?>" target="_blank" rel="noreferrer"><?= $this->text->e($plugin['title']) ?></a>
+ </th>
+ </tr>
+ <tr>
+ <td class="column-40">
+ <?= $this->text->e($plugin['author']) ?>
+ </td>
+ <td class="column-30">
+ <?= $this->text->e($plugin['version']) ?>
+ </td>
+ <td>
+ <?php if ($is_configured): ?>
+ <?php if (! isset($installed_plugins[$plugin['title']])): ?>
+ <i class="fa fa-cloud-download fa-fw" aria-hidden="true"></i>
+ <?= $this->url->link(t('Install'), 'PluginController', 'install', array('archive_url' => urlencode($plugin['download'])), true) ?>
+ <?php elseif ($installed_plugins[$plugin['title']] < $plugin['version']): ?>
+ <i class="fa fa-refresh fa-fw" aria-hidden="true"></i>
+ <?= $this->url->link(t('Update'), 'PluginController', 'update', array('archive_url' => urlencode($plugin['download'])), true) ?>
+ <?php else: ?>
+ <i class="fa fa-check-circle-o" aria-hidden="true"></i>
+ <?= t('Up to date') ?>
+ <?php endif ?>
+ <?php else: ?>
+ <i class="fa fa-ban fa-fw" aria-hidden="true"></i>
+ <?= t('Not available') ?>
+ <?php endif ?>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="3">
+ <div class="markdown">
+ <?= $this->text->markdown($plugin['description']) ?>
+ </div>
+ </td>
</tr>
-
- <?php foreach ($plugins as $plugin): ?>
- <tr>
- <td>
- <a href="<?= $plugin['homepage'] ?>" target="_blank" rel="noreferrer"><?= $this->text->e($plugin['title']) ?></a>
- </td>
- <td><?= $this->text->e($plugin['author']) ?></td>
- <td><?= $this->text->e($plugin['version']) ?></td>
- <td><?= $this->text->e($plugin['description']) ?></td>
- <td>
- </td>
- </tr>
- <?php endforeach ?>
</table>
+ <?php endforeach ?>
<?php endif ?>
diff --git a/app/Template/plugin/remove.php b/app/Template/plugin/remove.php
new file mode 100644
index 00000000..bd8f4eb8
--- /dev/null
+++ b/app/Template/plugin/remove.php
@@ -0,0 +1,13 @@
+<div class="page-header">
+ <h2><?= t('Remove plugin') ?></h2>
+</div>
+
+<div class="confirm">
+ <p class="alert alert-info"><?= t('Do you really want to remove this plugin: "%s"?', $plugin->getPluginName()) ?></p>
+
+ <div class="form-actions">
+ <?= $this->url->link(t('Yes'), 'PluginController', 'uninstall', array('pluginId' => $plugin_id), true, 'btn btn-red') ?>
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'PluginController', 'show', array(), false, 'close-popover') ?>
+ </div>
+</div>
diff --git a/app/Template/plugin/show.php b/app/Template/plugin/show.php
index 8358fb2a..9c3d6d20 100644
--- a/app/Template/plugin/show.php
+++ b/app/Template/plugin/show.php
@@ -5,15 +5,17 @@
<?php if (empty($plugins)): ?>
<p class="alert"><?= t('There is no plugin loaded.') ?></p>
<?php else: ?>
- <table class="table-stripped">
+ <table>
<tr>
- <th class="column-20"><?= t('Name') ?></th>
- <th class="column-20"><?= t('Author') ?></th>
+ <th class="column-35"><?= t('Name') ?></th>
+ <th class="column-30"><?= t('Author') ?></th>
<th class="column-10"><?= t('Version') ?></th>
- <th><?= t('Description') ?></th>
+ <?php if ($is_configured): ?>
+ <th><?= t('Action') ?></th>
+ <?php endif ?>
</tr>
- <?php foreach ($plugins as $plugin): ?>
+ <?php foreach ($plugins as $pluginFolder => $plugin): ?>
<tr>
<td>
<?php if ($plugin->getPluginHomepage()): ?>
@@ -24,7 +26,15 @@
</td>
<td><?= $this->text->e($plugin->getPluginAuthor()) ?></td>
<td><?= $this->text->e($plugin->getPluginVersion()) ?></td>
- <td><?= $this->text->e($plugin->getPluginDescription()) ?></td>
+ <?php if ($is_configured): ?>
+ <td>
+ <i class="fa fa-trash-o fa-fw" aria-hidden="true"></i>
+ <?= $this->url->link(t('Uninstall'), 'PluginController', 'confirm', array('pluginId' => $pluginFolder), false, 'popover') ?>
+ </td>
+ <?php endif ?>
+ </tr>
+ <tr>
+ <td colspan="<?= $is_configured ? 4 : 3 ?>"><?= $this->text->e($plugin->getPluginDescription()) ?></td>
</tr>
<?php endforeach ?>
</table>
diff --git a/app/constants.php b/app/constants.php
index 31510c5f..3c404d8b 100644
--- a/app/constants.php
+++ b/app/constants.php
@@ -12,8 +12,10 @@ defined('DATA_DIR') or define('DATA_DIR', ROOT_DIR.DIRECTORY_SEPARATOR.'data');
// Files directory (attachments)
defined('FILES_DIR') or define('FILES_DIR', DATA_DIR.DIRECTORY_SEPARATOR.'files');
-// Plugins directory
+// Plugins settings
defined('PLUGINS_DIR') or define('PLUGINS_DIR', ROOT_DIR.DIRECTORY_SEPARATOR.'plugins');
+defined('PLUGIN_API_URL') or define('PLUGIN_API_URL', 'https://kanboard.net/plugins.json');
+defined('PLUGIN_INSTALLER') or define('PLUGIN_INSTALLER', true);
// Enable/disable debug
defined('DEBUG') or define('DEBUG', strtolower(getenv('DEBUG')) === 'true');
@@ -131,5 +133,3 @@ defined('HTTP_PROXY_HOSTNAME') or define('HTTP_PROXY_HOSTNAME', '');
defined('HTTP_PROXY_PORT') or define('HTTP_PROXY_PORT', '3128');
defined('HTTP_PROXY_USERNAME') or define('HTTP_PROXY_USERNAME', '');
defined('HTTP_PROXY_PASSWORD') or define('HTTP_PROXY_PASSWORD', '');
-
-defined('PLUGIN_API_URL') or define('PLUGIN_API_URL', 'https://kanboard.net/plugins.json');
diff --git a/doc/cli.markdown b/doc/cli.markdown
index 20e3566a..96bffe2d 100644
--- a/doc/cli.markdown
+++ b/doc/cli.markdown
@@ -41,6 +41,10 @@ Available commands:
locale:sync Synchronize all translations based on the fr_FR locale
notification
notification:overdue-tasks Send notifications for overdue tasks
+ plugin
+ plugin:install Install a plugin from a remote Zip archive
+ plugin:uninstall Remove a plugin
+ plugin:upgrade Update all installed plugins
projects
projects:daily-stats Calculate daily statistics for all projects
trigger
@@ -170,3 +174,25 @@ You will be prompted for a password and confirmation. Characters are not printed
```bash
./kanboard user:reset-2fa my_user
```
+
+### Install a plugin
+
+```bash
+./kanboard plugin:install https://github.com/kanboard/plugin-github-auth/releases/download/v1.0.1/GithubAuth-1.0.1.zip
+```
+
+Note: Installed files will have the same permissions as the current user
+
+### Remove a plugin
+
+```bash
+./kanboard plugin:uninstall Budget
+```
+
+### Upgrade all plugins
+
+```bash
+./kanboard plugin:upgrade
+* Updating plugin: Budget Planning
+* Plugin up to date: Github Authentication
+```
diff --git a/doc/config.markdown b/doc/config.markdown
index 0e3c3198..0325358d 100644
--- a/doc/config.markdown
+++ b/doc/config.markdown
@@ -15,14 +15,21 @@ define('LOG_DRIVER', 'file'); // Other drivers are: syslog, stdout, stderr or fi
The log driver must be defined if you enable the debug mode.
The debug mode logs all SQL queries and the time taken to generate pages.
-Plugins folder
---------------
+Plugins
+-------
+
+Plugin folder:
```php
-// Plugin directory
define('PLUGINS_DIR', 'data/plugins');
```
+Enable/disable plugin installation from the user interface:
+
+```php
+define('PLUGIN_INSTALLER', true); // Default is true
+```
+
Folder for uploaded files
-------------------------
diff --git a/doc/index.markdown b/doc/index.markdown
index 5fc576d8..ee982dbb 100644
--- a/doc/index.markdown
+++ b/doc/index.markdown
@@ -110,6 +110,7 @@ Technical details
- [Environment variables](env.markdown)
- [Email configuration](email-configuration.markdown)
- [URL rewriting](nice-urls.markdown)
+- [Plugin Directory](plugin-directory.markdown)
### Database
diff --git a/doc/plugin-directory.markdown b/doc/plugin-directory.markdown
new file mode 100644
index 00000000..385e3360
--- /dev/null
+++ b/doc/plugin-directory.markdown
@@ -0,0 +1,15 @@
+Plugin Directory Configuration
+==============================
+
+To install, update and remove plugins from the user interface, you must have those requirements:
+
+- The plugin directory must be writeable by the web server user
+- The Zip extension must be available on your server
+- The config parameter `PLUGIN_INSTALLER` must be set at `true`
+
+To disable this feature, change the value of `PLUGIN_INSTALLER` to `false` in your config file.
+You can also change the permissions of the plugin folder on the filesystem.
+
+Only administrators are allowed to install plugins from the user interface.
+
+By default, only plugin listed on Kanboard's website are available.
diff --git a/kanboard b/kanboard
index 6a51c937..49f3fe17 100755
--- a/kanboard
+++ b/kanboard
@@ -1,8 +1,9 @@
#!/usr/bin/env php
<?php
-require __DIR__.'/app/common.php';
-
+use Kanboard\Console\PluginInstallCommand;
+use Kanboard\Console\PluginUninstallCommand;
+use Kanboard\Console\PluginUpgradeCommand;
use Kanboard\Console\ResetPasswordCommand;
use Kanboard\Console\ResetTwoFactorCommand;
use Symfony\Component\Console\Application;
@@ -18,19 +19,32 @@ use Kanboard\Console\LocaleComparatorCommand;
use Kanboard\Console\TaskTriggerCommand;
use Kanboard\Console\CronjobCommand;
-$container['dispatcher']->dispatch('app.bootstrap', new Event);
-$application = new Application('Kanboard', APP_VERSION);
-$application->add(new TaskOverdueNotificationCommand($container));
-$application->add(new SubtaskExportCommand($container));
-$application->add(new TaskExportCommand($container));
-$application->add(new ProjectDailyStatsCalculationCommand($container));
-$application->add(new ProjectDailyColumnStatsExportCommand($container));
-$application->add(new TransitionExportCommand($container));
-$application->add(new LocaleSyncCommand($container));
-$application->add(new LocaleComparatorCommand($container));
-$application->add(new TaskTriggerCommand($container));
-$application->add(new CronjobCommand($container));
-$application->add(new ResetPasswordCommand($container));
-$application->add(new ResetTwoFactorCommand($container));
-$application->run();
+try {
+
+ require __DIR__.'/app/common.php';
+
+ $container['dispatcher']->dispatch('app.bootstrap', new Event);
+
+ $application = new Application('Kanboard', APP_VERSION);
+ $application->add(new TaskOverdueNotificationCommand($container));
+ $application->add(new SubtaskExportCommand($container));
+ $application->add(new TaskExportCommand($container));
+ $application->add(new ProjectDailyStatsCalculationCommand($container));
+ $application->add(new ProjectDailyColumnStatsExportCommand($container));
+ $application->add(new TransitionExportCommand($container));
+ $application->add(new LocaleSyncCommand($container));
+ $application->add(new LocaleComparatorCommand($container));
+ $application->add(new TaskTriggerCommand($container));
+ $application->add(new CronjobCommand($container));
+ $application->add(new ResetPasswordCommand($container));
+ $application->add(new ResetTwoFactorCommand($container));
+ $application->add(new PluginUpgradeCommand($container));
+ $application->add(new PluginInstallCommand($container));
+ $application->add(new PluginUninstallCommand($container));
+ $application->run();
+
+} catch (Exception $e) {
+ echo $e->getMessage().PHP_EOL;
+ exit(255);
+}