feat(plugins): activate / deactivate plugin using settings table

+ load plugin icon
+ add pagination
+ autoload plugins in Config/Autoload.php to handle plugin
i18n
+ style plugin cards
This commit is contained in:
Yassine Doghri 2024-05-01 14:48:05 +00:00
parent 2f517fde47
commit 0eba234628
16 changed files with 407 additions and 43 deletions

View File

@ -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<string>
*/
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();
}
}

View File

@ -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();

View File

@ -23,6 +23,7 @@ return [
'add' => 'add',
'new' => 'new',
'edit' => 'edit',
'plugins' => 'plugins',
'persons' => 'persons',
'publish' => 'publish',
'publish-edit' => 'edit publication',

View File

@ -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',

View File

@ -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',

View File

@ -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>|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<string, string|list<string>>
*/
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<mixed>|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
);
}
}

View File

@ -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',
]);
});
}
);

View File

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
use Modules\Admin\Controllers\BaseController;
class Controller extends BaseController
{
public function index(): string
{
$plugins = service('plugins');
return view('plugins', [
'installedPlugins' => $plugins->getInstalled(),
]);
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Modules\Plugins\Controllers;
use CodeIgniter\HTTP\RedirectResponse;
use Modules\Admin\Controllers\BaseController;
use Modules\Plugins\Plugins;
class PluginsController extends BaseController
{
public function installed(): string
{
/** @var Plugins $plugins */
$plugins = service('plugins');
$pager = service('pager');
$page = (int) ($this->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();
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
if (! function_exists('get_plugin_option')) {
function get_plugin_option(string $pluginKey, string $option): mixed
{
$key = sprintf('Plugins.%s', $option);
$context = sprintf('plugin:%s', $pluginKey);
return setting()->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);
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/**
* @copyright 2022 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
"installed" => "Installed plugins ({count})",
"website" => "Website",
"activate" => "Activate",
"deactivate" => "Deactivate",
"keywords" => [
'podcasting20' => 'Podcasting 2.0',
'seo' => 'SEO',
'analytics' => 'Analytics',
'accessibility' => 'Accessibility',
],
];

View File

@ -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<PluginInterface>
* @var list<string>
*/
protected const HOOKS = ['setChannelTag', 'setItemTag'];
/**
* @var array<BasePlugin>
*/
protected static array $plugins = [];
protected static int $installedCount = 0;
public function __construct()
{
helper('plugins');
$this->registerPlugins();
}
/**
* @return array<PluginInterface>
* @param value-of<static::HOOKS> $name
* @param array<mixed> $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<mixed> $parameters
* @return array<BasePlugin>
*/
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<static::HOOKS> $name
* @param array<mixed> $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;
}
}
}

View File

@ -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",

View File

@ -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'],

View File

@ -0,0 +1,23 @@
<article class="flex flex-col p-4 rounded-xl bg-elevated border-3 <?= $plugin->isActive() ? 'border-accent-base' : 'border-subtle' ?>">
<img class="rounded-full min-w-16 max-w-16 aspect-square" src="<?= $plugin->iconSrc ?>">
<div class="flex flex-col mt-2">
<h2 class="flex items-center text-xl font-bold font-display gap-x-2"><?= $plugin->getName() ?><span class="px-1 font-mono text-xs rounded-full bg-subtle"><?= $plugin->version ?></span></h2>
<p class="text-gray-600"><?= $plugin->getDescription() ?></p>
</div>
<footer class="flex items-center justify-between mt-4">
<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; ?>
</footer>
</article>

View File

@ -0,0 +1,24 @@
<?= $this->extend('_layout') ?>
<?= $this->section('title') ?>
<?= lang('Plugins.installed', [
'count' => $total,
]) ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('Plugins.installed', [
'count' => $total,
]) ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<div class="grid gap-4 mb-4 grid-cols-plugins">
<?php foreach ($plugins as $plugin) {
echo view('plugins/_plugin', [
'plugin' => $plugin,
]);
} ?>
</div>
<?= $pager_links ?>
<?= $this->endSection() ?>