diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php index 2f48aa63..69589a22 100644 --- a/app/Config/Autoload.php +++ b/app/Config/Autoload.php @@ -55,7 +55,6 @@ class Autoload extends AutoloadConfig 'Modules\PremiumPodcasts' => ROOTPATH . 'modules/PremiumPodcasts/', 'Modules\Update' => ROOTPATH . 'modules/Update/', 'Modules\WebSub' => ROOTPATH . 'modules/WebSub/', - 'Plugins' => ROOTPATH . 'plugins', 'Themes' => ROOTPATH . 'themes', 'ViewComponents' => APPPATH . 'Libraries/ViewComponents/', 'ViewThemes' => APPPATH . 'Libraries/ViewThemes/', @@ -111,4 +110,20 @@ class Autoload extends AutoloadConfig * @var list */ public $helpers = ['auth', 'setting', 'icons']; + + public function __construct() + { + // load plugins namespaces + $pluginsPaths = glob(ROOTPATH . '/plugins/*', GLOB_ONLYDIR); + + if (! $pluginsPaths) { + $pluginsPaths = []; + } + + foreach ($pluginsPaths as $pluginPath) { + $this->psr4[sprintf('Plugins\%s', basename($pluginPath))] = $pluginPath; + } + + parent::__construct(); + } } diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php index bd13598e..dc5e1cee 100644 --- a/app/Helpers/rss_helper.php +++ b/app/Helpers/rss_helper.php @@ -16,6 +16,7 @@ use CodeIgniter\I18n\Time; use Config\Mimes; use Modules\Media\Entities\Chapters; use Modules\Media\Entities\Transcript; +use Modules\Plugins\Plugins; use Modules\PremiumPodcasts\Entities\Subscription; if (! function_exists('get_rss_feed')) { @@ -31,6 +32,7 @@ if (! function_exists('get_rss_feed')) { Subscription $subscription = null, string $token = null ): string { + /** @var Plugins $plugins */ $plugins = service('plugins'); $episodes = $podcast->episodes; @@ -71,8 +73,6 @@ if (! function_exists('get_rss_feed')) { $channel->addChild('generator', 'Castopod - https://castopod.org/'); $channel->addChild('docs', 'https://cyber.harvard.edu/rss/rss.html'); - $plugins->runHook('setChannelTag', [$podcast, $channel]); - if ($podcast->guid === '') { // FIXME: guid shouldn't be empty here as it should be filled upon Podcast creation $uuid = service('uuid'); @@ -297,6 +297,9 @@ if (! function_exists('get_rss_feed')) { ], $channel); } + // run plugins hook at the end + $plugins->setChannelTag($podcast, $channel); + foreach ($episodes as $episode) { if ($episode->is_premium && ! $subscription instanceof Subscription) { continue; @@ -456,6 +459,8 @@ if (! function_exists('get_rss_feed')) { 'elements' => $episode->custom_rss, ], $item); } + + $plugins->setItemTag($episode, $item); } return $rss->asXML(); diff --git a/modules/Admin/Language/en/Breadcrumb.php b/modules/Admin/Language/en/Breadcrumb.php index 408c9f9f..fedd6b19 100644 --- a/modules/Admin/Language/en/Breadcrumb.php +++ b/modules/Admin/Language/en/Breadcrumb.php @@ -23,6 +23,7 @@ return [ 'add' => 'add', 'new' => 'new', 'edit' => 'edit', + 'plugins' => 'plugins', 'persons' => 'persons', 'publish' => 'publish', 'publish-edit' => 'edit publication', diff --git a/modules/Admin/Language/en/Navigation.php b/modules/Admin/Language/en/Navigation.php index f3ffb129..fc7cfc30 100644 --- a/modules/Admin/Language/en/Navigation.php +++ b/modules/Admin/Language/en/Navigation.php @@ -20,6 +20,8 @@ return [ 'podcast-create' => 'New podcast', 'all-podcast-imports' => 'All Podcast imports', 'podcast-imports-add' => 'Import a podcast', + 'plugins' => 'Plugins', + 'plugins-installed' => 'Installed', 'persons' => 'Persons', 'person-list' => 'All persons', 'person-create' => 'New person', diff --git a/modules/Auth/Config/AuthGroups.php b/modules/Auth/Config/AuthGroups.php index 06fe419b..9d3ff5a8 100644 --- a/modules/Auth/Config/AuthGroups.php +++ b/modules/Auth/Config/AuthGroups.php @@ -107,6 +107,7 @@ class AuthGroups extends ShieldAuthGroups public array $instanceBasePermissions = [ 'admin.access', 'admin.settings', + 'plugins.manage', 'users.manage', 'persons.manage', 'pages.manage', @@ -122,6 +123,7 @@ class AuthGroups extends ShieldAuthGroups public array $instanceMatrix = [ 'superadmin' => [ 'admin.*', + 'plugins.*', 'podcasts.*', 'users.manage', 'persons.manage', diff --git a/modules/Plugins/BasePlugin.php b/modules/Plugins/BasePlugin.php index 5a61d2b8..d73683c1 100644 --- a/modules/Plugins/BasePlugin.php +++ b/modules/Plugins/BasePlugin.php @@ -7,13 +7,49 @@ namespace Modules\Plugins; use App\Entities\Episode; use App\Entities\Podcast; use App\Libraries\SimpleRSSElement; +use CodeIgniter\I18n\Time; +use RuntimeException; +/** + * @property string $name + * @property string $description + * @property string $version + * @property string $website + * @property Time $releaseDate + * @property string $author + * @property string $license + * @property string $compatible + * @property string[] $keywords + * @property string $iconSrc + */ abstract class BasePlugin implements PluginInterface { - public function __construct() + protected bool $active; + + public function __construct( + protected string $key, + protected string $filePath + ) { + $pluginDirectory = dirname($filePath); + + $manifest = $this->loadManifest($pluginDirectory . '/manifest.json'); + + foreach ($manifest as $key => $value) { + $this->{$key} = $value; + } + + // check that plugin is active + $this->active = get_plugin_option($this->key, 'active') ?? false; + + $this->iconSrc = $this->loadIcon($pluginDirectory . '/icon.svg'); + } + + /** + * @param list|string $value + */ + public function __set(string $name, array|string $value): void { - // load metadata from json - // load name, description, etc. + $this->{$name} = $name === 'releaseDate' ? Time::createFromFormat('Y-m-d', $value) : $value; } public function init(): void @@ -30,4 +66,113 @@ abstract class BasePlugin implements PluginInterface public function setItemTag(Episode $episode, SimpleRSSElement $item): void { } + + final public function isActive(): bool + { + return $this->active; + } + + final public function getKey(): string + { + return $this->key; + } + + final public function getName(): string + { + $key = sprintf('Plugin.%s.name', $this->key); + /** @var string $name */ + $name = lang($key); + + if ($name === $key) { + return $this->name; + } + + return $name; + } + + final public function getDescription(): string + { + $key = sprintf('Plugin.%s.description', $this->key); + + /** @var string $description */ + $description = lang($key); + + if ($description === $key) { + return $this->description; + } + + return $description; + } + + final protected function getOption(string $option): mixed + { + return get_plugin_option($this->key, $option); + } + + final protected function setOption(string $option, mixed $value = null): void + { + set_plugin_option($this->key, $option, $value); + } + + /** + * @return array> + */ + private function loadManifest(string $path): array + { + // TODO: cache manifest data + + $manifestContents = file_get_contents($path); + + if (! $manifestContents) { + throw new RuntimeException('manifest file not found!'); + } + + /** @var array|null $manifest */ + $manifest = json_decode($manifestContents, true); + + if ($manifest === null) { + throw new RuntimeException('manifest.json is not a valid JSON', 1); + } + + $rules = [ + 'name' => 'required|max_length[32]', + 'version' => 'required|regex_match[/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/]', + 'compatible' => 'required|in_list[1.0]', + 'description' => 'max_length[128]', + 'releaseDate' => 'valid_date[Y-m-d]', + 'license' => 'in_list[MIT]', + 'author.name' => 'max_length[32]', + 'author.email' => 'valid_email', + 'author.url' => 'valid_url_strict', + 'website' => 'valid_url_strict', + 'keywords.*' => 'in_list[seo,podcasting20,analytics]', + ]; + + $validation = service('validation'); + + $validation->setRules($rules); + + if (! $validation->run($manifest)) { + dd($validation->getErrors()); + } + + return $validation->getValidated(); + } + + private function loadIcon(string $path): string + { + // TODO: cache icon + $svgIcon = @file_get_contents($path); + + if (! $svgIcon) { + return "data:image/svg+xml;utf8,%3Csvg xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg' viewBox='0 0 64 64'%3E%3Cpath fill='%2300564A' d='M0 0h64v64H0z'%2F%3E%3Cpath fill='%23E7F9E4' d='M25.3 18.7a5 5 0 1 1 9.7 1.6h7c1 0 1.7.8 1.7 1.7v7a5 5 0 1 1 0 9.4v7c0 .9-.8 1.6-1.7 1.6H18.7c-1 0-1.7-.7-1.7-1.7V22c0-1 .7-1.7 1.7-1.7h7a5 5 0 0 1-.4-1.6Z'%2F%3E%3C%2Fsvg%3E"; + } + + $encodedIcon = rawurlencode(str_replace(["\r", "\n"], ' ', $svgIcon)); + return 'data:image/svg+xml;utf8,' . str_replace( + ['%20', '%22', '%27', '%3D'], + [' ', "'", "'", '='], + $encodedIcon + ); + } } diff --git a/modules/Plugins/Config/Routes.php b/modules/Plugins/Config/Routes.php index 984bbc5a..ff80bddd 100644 --- a/modules/Plugins/Config/Routes.php +++ b/modules/Plugins/Config/Routes.php @@ -12,9 +12,19 @@ $routes->group( 'namespace' => 'Modules\Plugins\Controllers', ], static function ($routes): void { - $routes->get('plugins', 'PluginsController', [ - 'as' => 'plugins', - 'filter' => 'permission:podcasts.import', - ]); + $routes->group('plugins', static function ($routes): void { + $routes->get('/', 'PluginsController::installed', [ + 'as' => 'plugins-installed', + 'filter' => 'permission:plugins.manage', + ]); + $routes->post('activate/(:segment)', 'PluginsController::activate/$1', [ + 'as' => 'plugins-activate', + 'filter' => 'permission:plugins.manage', + ]); + $routes->post('deactivate/(:segment)', 'PluginsController::deactivate/$1', [ + 'as' => 'plugins-deactivate', + 'filter' => 'permission:plugins.manage', + ]); + }); } ); diff --git a/modules/Plugins/Controllers/PluginController.php b/modules/Plugins/Controllers/PluginController.php deleted file mode 100644 index 8e9e5438..00000000 --- a/modules/Plugins/Controllers/PluginController.php +++ /dev/null @@ -1,17 +0,0 @@ - $plugins->getInstalled(), - ]); - } -} diff --git a/modules/Plugins/Controllers/PluginsController.php b/modules/Plugins/Controllers/PluginsController.php new file mode 100644 index 00000000..dc3989c9 --- /dev/null +++ b/modules/Plugins/Controllers/PluginsController.php @@ -0,0 +1,46 @@ +request->getGet('page') ?? 1); + $perPage = 10; + $total = $plugins->getInstalledCount(); + + $pager_links = $pager->makeLinks($page, $perPage, $total); + + return view('plugins/installed', [ + 'total' => $total, + 'plugins' => $plugins->getPlugins($page, $perPage), + 'pager_links' => $pager_links, + ]); + } + + public function activate(string $pluginKey): RedirectResponse + { + service('plugins')->activate($pluginKey); + + return redirect()->back(); + } + + public function deactivate(string $pluginKey): RedirectResponse + { + service('plugins')->deactivate($pluginKey); + + return redirect()->back(); + } +} diff --git a/modules/Plugins/Helpers/plugins_helper.php b/modules/Plugins/Helpers/plugins_helper.php new file mode 100644 index 00000000..8b0e205e --- /dev/null +++ b/modules/Plugins/Helpers/plugins_helper.php @@ -0,0 +1,24 @@ +get($key, $context); + } +} + +if (! function_exists('set_plugin_option')) { + function set_plugin_option(string $pluginKey, string $option, mixed $value = null): void + { + $key = sprintf('Plugins.%s', $option); + $context = sprintf('plugin:%s', $pluginKey); + + setting() + ->set($key, $value, $context); + } +} diff --git a/modules/Plugins/Language/en/Plugins.php b/modules/Plugins/Language/en/Plugins.php new file mode 100644 index 00000000..f801caa1 --- /dev/null +++ b/modules/Plugins/Language/en/Plugins.php @@ -0,0 +1,22 @@ + "Installed plugins ({count})", + "website" => "Website", + "activate" => "Activate", + "deactivate" => "Deactivate", + "keywords" => [ + 'podcasting20' => 'Podcasting 2.0', + 'seo' => 'SEO', + 'analytics' => 'Analytics', + 'accessibility' => 'Accessibility', + ], +]; diff --git a/modules/Plugins/Plugins.php b/modules/Plugins/Plugins.php index 9dc5bd57..48c6a42a 100644 --- a/modules/Plugins/Plugins.php +++ b/modules/Plugins/Plugins.php @@ -4,59 +4,111 @@ declare(strict_types=1); namespace Modules\Plugins; +use App\Entities\Episode; +use App\Entities\Podcast; +use App\Libraries\SimpleRSSElement; + +/** + * @method void setChannelTag(Podcast $podcast, SimpleRSSElement $channel) + * @method void setItemTag(Episode $episode, SimpleRSSElement $item) + */ class Plugins { + protected const API_VERSION = '1.0'; + /** - * @var array + * @var list + */ + protected const HOOKS = ['setChannelTag', 'setItemTag']; + + /** + * @var array */ protected static array $plugins = []; + protected static int $installedCount = 0; + public function __construct() { + helper('plugins'); + $this->registerPlugins(); } /** - * @return array + * @param value-of $name + * @param array $arguments */ - public function getPlugins(): array + public function __call(string $name, array $arguments): void { - return $this->plugins; + if (! in_array($name, static::HOOKS, true)) { + return; + } + + $this->runHook($name, $arguments); } /** - * @param array $parameters + * @return array */ - public function runHook(string $name, array $parameters): void + public function getPlugins(int $page, int $perPage): array + { + return array_slice(static::$plugins, (($page - 1) * $perPage), $perPage); + } + + /** + * @param value-of $name + * @param array $arguments + */ + public function runHook(string $name, array $arguments): void { - dd(static::$plugins); - // only run active plugins' hooks foreach (static::$plugins as $plugin) { - $plugin->{$name}(...$parameters); + // only run hook on active plugins + if ($plugin->isActive()) { + $plugin->{$name}(...$arguments); + } } } + public function activate(string $pluginKey): void + { + set_plugin_option($pluginKey, 'active', true); + } + + public function deactivate(string $pluginKey): void + { + set_plugin_option($pluginKey, 'active', false); + } + + public function getInstalledCount(): int + { + return static::$installedCount; + } + protected function registerPlugins(): void { + // search for plugins in plugins folder + $pluginsFiles = glob(ROOTPATH . '/plugins/**/Plugin.php', GLOB_NOSORT); + + if (! $pluginsFiles) { + return; + } + $locator = service('locator'); - $pluginsFiles = $locator->search('HelloWorld/Plugin.php'); - - // dd($pluginsFiles); - foreach ($pluginsFiles as $file) { $className = $locator->findQualifiedNameFromPath($file); - dd($file); if ($className === false) { continue; } - $plugin = new $className(); - if (! $plugin instanceof PluginInterface) { + $plugin = new $className(basename(dirname($file)), $file); + if (! $plugin instanceof BasePlugin) { continue; } static::$plugins[] = $plugin; + ++static::$installedCount; } } } diff --git a/tailwind.config.cjs b/tailwind.config.cjs index 717a152c..2e7248f1 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -118,6 +118,7 @@ module.exports = { latestEpisodes: "repeat(5, 1fr)", colorButtons: "repeat(auto-fill, minmax(4rem, 1fr))", platforms: "repeat(auto-fill, minmax(18rem, 1fr))", + plugins: "repeat(auto-fill, minmax(20rem, 1fr))", }, gridTemplateRows: { admin: "40px 1fr", diff --git a/themes/cp_admin/_sidebar.php b/themes/cp_admin/_sidebar.php index 8279b8e3..d33f6518 100644 --- a/themes/cp_admin/_sidebar.php +++ b/themes/cp_admin/_sidebar.php @@ -21,6 +21,15 @@ $navigation = [ 'add-cta' => 'podcast-create', 'count-route' => 'podcast-list', ], + 'plugins' => [ + 'icon' => 'puzzle-fill', // @icon('puzzle-fill') + 'items' => ['plugins-installed'], + 'items-permissions' => [ + 'plugins-installed' => 'plugins.manage', + ], + 'count' => service('plugins')->getInstalledCount(), + 'count-route' => 'plugins-installed', + ], 'persons' => [ 'icon' => 'folder-user-fill', // @icon('folder-user-fill') 'items' => ['person-list', 'person-create'], diff --git a/themes/cp_admin/plugins/_plugin.php b/themes/cp_admin/plugins/_plugin.php new file mode 100644 index 00000000..fe0f41a7 --- /dev/null +++ b/themes/cp_admin/plugins/_plugin.php @@ -0,0 +1,23 @@ + \ No newline at end of file diff --git a/themes/cp_admin/plugins/installed.php b/themes/cp_admin/plugins/installed.php new file mode 100644 index 00000000..4038623f --- /dev/null +++ b/themes/cp_admin/plugins/installed.php @@ -0,0 +1,24 @@ +extend('_layout') ?> + +section('title') ?> + $total, +]) ?> +endSection() ?> + +section('pageTitle') ?> + $total, +]) ?> +endSection() ?> + +section('content') ?> +
+ $plugin, + ]); +} ?> +
+ +endSection() ?>