feat(plugins): uninstall plugins via CLI and admin UI

This commit is contained in:
Yassine Doghri 2024-05-05 09:14:30 +00:00
parent 5dd94ee6e0
commit 1ff91a8546
11 changed files with 188 additions and 28 deletions

View File

@ -114,7 +114,7 @@ class Autoload extends AutoloadConfig
public function __construct()
{
// load plugins namespaces
$pluginsPaths = glob(ROOTPATH . '/plugins/*', GLOB_ONLYDIR | GLOB_NOSORT);
$pluginsPaths = glob(PLUGINS_PATH . '*', GLOB_ONLYDIR | GLOB_NOSORT);
if (! $pluginsPaths) {
$pluginsPaths = [];

View File

@ -28,6 +28,16 @@ defined('CP_VERSION') || define('CP_VERSION', '1.11.0');
*/
defined('APP_NAMESPACE') || define('APP_NAMESPACE', 'App');
/*
| --------------------------------------------------------------------
| Plugins Path
| --------------------------------------------------------------------
|
| This defines the folder in which plugins will live.
*/
defined('PLUGINS_PATH') ||
define('PLUGINS_PATH', ROOTPATH . 'plugins' . DIRECTORY_SEPARATOR);
/*
| --------------------------------------------------------------------------
| Composer Path

View File

@ -5,15 +5,6 @@
}
}
.ring-accent {
@apply outline-none ring-2 ring-offset-2;
/* FIXME: why doesn't ring-accent-base work? */
--tw-ring-opacity: 1;
--tw-ring-color: hsl(var(--color-accent-base) / var(--tw-ring-opacity));
--tw-ring-offset-color: hsl(var(--color-background-base));
}
.rounded-conditional-b-xl {
border-bottom-right-radius: max(
0px,

View File

@ -28,12 +28,12 @@ class Button extends Component
public function render(): string
{
$baseClass =
'gap-x-2 flex-shrink-0 inline-flex items-center justify-center font-semibold rounded-full focus:ring-accent';
'gap-x-2 flex-shrink-0 inline-flex items-center justify-center font-semibold rounded-full';
$variantClass = [
'default' => 'shadow-sm text-black bg-gray-300 hover:bg-gray-400',
'primary' => 'shadow-sm text-accent-contrast bg-accent-base hover:bg-accent-hover',
'secondary' => 'shadow-sm border-2 border-accent-base text-accent-base bg-white hover:border-accent-hover hover:text-accent-hover',
'secondary' => 'shadow-sm ring-2 ring-accent ring-inset text-accent-base bg-white hover:border-accent-hover hover:text-accent-hover',
'success' => 'shadow-sm text-white bg-pine-500 hover:bg-pine-800',
'danger' => 'shadow-sm text-white bg-red-600 hover:bg-red-700',
'warning' => 'shadow-sm text-black bg-yellow-500 hover:bg-yellow-600',

View File

@ -37,7 +37,7 @@ class DropdownMenu extends Component
switch ($item['type']) {
case 'link':
$menuItems .= anchor($item['uri'], $item['title'], [
'class' => 'px-4 py-1 hover:bg-highlight focus:ring-accent focus:ring-inset' . (array_key_exists('class', $item) ? ' ' . $item['class'] : ''),
'class' => 'inline-flex gap-x-1 items-center px-4 py-1 hover:bg-highlight focus:ring-accent focus:ring-inset' . (array_key_exists('class', $item) ? ' ' . $item['class'] : ''),
]);
break;
case 'html':

View File

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Modules\Plugins\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
class UninstallPlugin extends BaseCommand
{
/**
* The Command's Group
*
* @var string
*/
protected $group = 'Plugins';
/**
* The Command's Name
*
* @var string
*/
protected $name = 'plugins:uninstall';
/**
* The Command's Description
*
* @var string
*/
protected $description = '';
/**
* The Command's Usage
*
* @var string
*/
protected $usage = 'plugins:uninstall [plugins]';
/**
* The Command's Arguments
*
* @var array<string, string>
*/
protected $arguments = [
'plugins' => 'One or more plugins as vendor/plugin',
];
/**
* @param list<string> $pluginKeys
*/
public function run(array $pluginKeys): int
{
$validation = service('validation');
/** @var list<string> $errors */
$errors = [];
foreach ($pluginKeys as $pluginKey) {
// TODO: change validation of pluginKey
if (! $validation->check($pluginKey, 'required')) {
$errors = [...$errors, ...$validation->getErrors()];
continue;
}
if (! service('plugins')->uninstall($pluginKey)) {
$errors[] = sprintf('Something happened when removing %s', $pluginKey);
}
}
foreach ($errors as $error) {
CLI::error($error . PHP_EOL);
}
return $errors === [] ? 0 : 1;
}
}

View File

@ -25,14 +25,19 @@ $routes->group(
'as' => 'plugins-general-settings-action',
'filter' => 'permission:plugins.manage',
]);
$routes->post('activate/(:segment)', 'PluginController::activate/$1', [
$routes->post('(:segment)/activate', 'PluginController::activate/$1', [
'as' => 'plugins-activate',
'filter' => 'permission:plugins.manage',
]);
$routes->post('deactivate/(:segment)', 'PluginController::deactivate/$1', [
$routes->post('(:segment)/deactivate', 'PluginController::deactivate/$1', [
'as' => 'plugins-deactivate',
'filter' => 'permission:plugins.manage',
]);
// TODO: change to delete
$routes->get('(:segment)/uninstall', 'PluginController::uninstall/$1', [
'as' => 'plugins-uninstall',
'filter' => 'permission:plugins.manage',
]);
});
$routes->group('podcasts/(:num)/plugins', static function ($routes): void {
$routes->get('(:segment)', 'PluginController::podcastSettings/$1/$2', [

View File

@ -190,4 +190,11 @@ class PluginController extends BaseController
return redirect()->back();
}
public function uninstall(string $pluginKey): RedirectResponse
{
service('plugins')->uninstall($pluginKey);
return redirect()->back();
}
}

View File

@ -14,6 +14,7 @@ return [
'settings' => '{pluginName} settings',
'activate' => 'Activate',
'deactivate' => 'Deactivate',
'uninstall' => 'Uninstall',
'keywords' => [
'podcasting20' => 'Podcasting 2.0',
'seo' => 'SEO',

View File

@ -7,6 +7,7 @@ namespace Modules\Plugins;
use App\Entities\Episode;
use App\Entities\Podcast;
use App\Libraries\SimpleRSSElement;
use Config\Database;
/**
* @method void channelTag(Podcast $podcast, SimpleRSSElement $channel)
@ -153,11 +154,35 @@ class Plugins
return static::$installedCount;
}
public function uninstall(string $pluginKey): bool
{
// remove all settings data
$db = Database::connect();
$builder = $db->table('settings');
$db->transStart();
$builder->where('class', self::class);
$builder->like('context', sprintf('plugin:%s', $pluginKey . '%'));
if (! $builder->delete()) {
$db->transRollback();
return false;
}
// delete plugin folder from PLUGINS_PATH
$pluginFolder = PLUGINS_PATH . $pluginKey;
$rmdirResult = $this->rrmdir($pluginFolder);
$transResult = $db->transCommit();
return $rmdirResult && $transResult;
}
protected function registerPlugins(): void
{
// search for plugins in plugins folder
// TODO: only get directories? Should be organized as author/repo?
$pluginsFiles = glob(ROOTPATH . '/plugins/**/Plugin.php');
$pluginsFiles = glob(PLUGINS_PATH . '**/Plugin.php');
if (! $pluginsFiles) {
return;
@ -180,4 +205,38 @@ class Plugins
++static::$installedCount;
}
}
/**
* Adapted from https://stackoverflow.com/a/3338133
*/
private function rrmdir(string $dir): bool
{
if (! is_dir($dir)) {
return false;
}
$objects = scandir($dir);
if (! $objects) {
return false;
}
foreach ($objects as $object) {
if ($object === '.') {
continue;
}
if ($object === '..') {
continue;
}
if (is_dir($dir . DIRECTORY_SEPARATOR . $object) && ! is_link($dir . '/' . $object)) {
$this->rrmdir($dir . DIRECTORY_SEPARATOR . $object);
} else {
unlink($dir . DIRECTORY_SEPARATOR . $object);
}
}
return rmdir($dir);
}
}

View File

@ -14,17 +14,28 @@
<a href="<?= $plugin->website ?>" class="inline-flex items-center text-sm font-semibold underline hover:no-underline gap-x-1" target="_blank" rel="noopener noreferrer"><?= icon('link', [
'class' => 'text-gray-500',
]) . lang('Plugins.website') ?></a>
<?php if($plugin->isActive()): ?>
<form class="flex justify-end" method="POST" action="<?= route_to('plugins-deactivate', $plugin->getKey()) ?>">
<?= csrf_field() ?>
<Button type="submit" variant="danger" size="small"><?= lang('Plugins.deactivate') ?></Button>
</form>
<?php else: ?>
<form class="flex justify-end" method="POST" action="<?= route_to('plugins-activate', $plugin->getKey()) ?>">
<?= csrf_field() ?>
<Button type="submit" variant="secondary" size="small"><?= lang('Plugins.activate') ?></Button>
</form>
<?php endif; ?>
<div class="flex gap-x-2">
<?php if($plugin->isActive()): ?>
<form class="flex justify-end" method="POST" action="<?= route_to('plugins-deactivate', $plugin->getKey()) ?>">
<?= csrf_field() ?>
<Button type="submit" variant="danger" size="small"><?= lang('Plugins.deactivate') ?></Button>
</form>
<?php else: ?>
<form class="flex justify-end" method="POST" action="<?= route_to('plugins-activate', $plugin->getKey()) ?>">
<?= csrf_field() ?>
<Button type="submit" variant="secondary" size="small"><?= lang('Plugins.activate') ?></Button>
</form>
<?php endif; ?>
<button class="p-2 rounded-full" id="more-dropdown-<?= $plugin->getKey() ?>" data-dropdown="button" data-dropdown-target="more-dropdown-<?= $plugin->getKey() ?>-menu" aria-haspopup="true" aria-expanded="false" title="<?= lang('Common.more') ?>"><?= icon('more-2-fill') ?></button>
<?php $items = [[
'type' => 'link',
'title' => icon('delete-bin-fill', [
'class' => 'text-gray-500',
]) . lang('Plugins.uninstall'),
'uri' => route_to('plugins-uninstall', $plugin->getKey()),
'class' => 'font-semibold text-red-600',
]]; ?>
<DropdownMenu id="more-dropdown-<?= $plugin->getKey() ?>-menu" labelledby="more-dropdown-<?= $plugin->getKey() ?>" placement="top-end" offsetY="-32" items="<?= esc(json_encode($items)) ?>" />
</div>
</footer>
</article>