diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php index cb8830b9..fd633162 100644 --- a/app/Config/Autoload.php +++ b/app/Config/Autoload.php @@ -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 = []; diff --git a/app/Config/Constants.php b/app/Config/Constants.php index 90eac0e4..fd181a82 100644 --- a/app/Config/Constants.php +++ b/app/Config/Constants.php @@ -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 diff --git a/app/Resources/styles/custom.css b/app/Resources/styles/custom.css index 6dfa93b1..adeb38e7 100644 --- a/app/Resources/styles/custom.css +++ b/app/Resources/styles/custom.css @@ -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, diff --git a/app/Views/Components/Button.php b/app/Views/Components/Button.php index 4d024339..28674dfb 100644 --- a/app/Views/Components/Button.php +++ b/app/Views/Components/Button.php @@ -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', diff --git a/app/Views/Components/DropdownMenu.php b/app/Views/Components/DropdownMenu.php index 8c062311..2a50ea71 100644 --- a/app/Views/Components/DropdownMenu.php +++ b/app/Views/Components/DropdownMenu.php @@ -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': diff --git a/modules/Plugins/Commands/UninstallPlugin.php b/modules/Plugins/Commands/UninstallPlugin.php new file mode 100644 index 00000000..a33f34c0 --- /dev/null +++ b/modules/Plugins/Commands/UninstallPlugin.php @@ -0,0 +1,76 @@ + + */ + protected $arguments = [ + 'plugins' => 'One or more plugins as vendor/plugin', + ]; + + /** + * @param list $pluginKeys + */ + public function run(array $pluginKeys): int + { + $validation = service('validation'); + + /** @var list $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; + } +} diff --git a/modules/Plugins/Config/Routes.php b/modules/Plugins/Config/Routes.php index 5ec00065..4d7ad64a 100644 --- a/modules/Plugins/Config/Routes.php +++ b/modules/Plugins/Config/Routes.php @@ -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', [ diff --git a/modules/Plugins/Controllers/PluginController.php b/modules/Plugins/Controllers/PluginController.php index fd40049e..cd8b86d4 100644 --- a/modules/Plugins/Controllers/PluginController.php +++ b/modules/Plugins/Controllers/PluginController.php @@ -190,4 +190,11 @@ class PluginController extends BaseController return redirect()->back(); } + + public function uninstall(string $pluginKey): RedirectResponse + { + service('plugins')->uninstall($pluginKey); + + return redirect()->back(); + } } diff --git a/modules/Plugins/Language/en/Plugins.php b/modules/Plugins/Language/en/Plugins.php index 52ea95a0..49a611b4 100644 --- a/modules/Plugins/Language/en/Plugins.php +++ b/modules/Plugins/Language/en/Plugins.php @@ -14,6 +14,7 @@ return [ 'settings' => '{pluginName} settings', 'activate' => 'Activate', 'deactivate' => 'Deactivate', + 'uninstall' => 'Uninstall', 'keywords' => [ 'podcasting20' => 'Podcasting 2.0', 'seo' => 'SEO', diff --git a/modules/Plugins/Plugins.php b/modules/Plugins/Plugins.php index 24616ebc..18b6cf47 100644 --- a/modules/Plugins/Plugins.php +++ b/modules/Plugins/Plugins.php @@ -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); + } } diff --git a/themes/cp_admin/plugins/_plugin.php b/themes/cp_admin/plugins/_plugin.php index d6833b66..5f4b5f01 100644 --- a/themes/cp_admin/plugins/_plugin.php +++ b/themes/cp_admin/plugins/_plugin.php @@ -14,17 +14,28 @@ 'text-gray-500', ]) . lang('Plugins.website') ?> - - isActive()): ?> -
- - -
- -
- - -
- +
+ isActive()): ?> +
+ + +
+ +
+ + +
+ + + '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', + ]]; ?> + +
\ No newline at end of file