diff --git a/modules/Plugins/Config/Routes.php b/modules/Plugins/Config/Routes.php index f3c1d42a..5a6b12e9 100644 --- a/modules/Plugins/Config/Routes.php +++ b/modules/Plugins/Config/Routes.php @@ -25,11 +25,15 @@ $routes->group( 'filter' => 'permission:plugins.manage', ]); $routes->group('(:pluginKey)', static function ($routes): void { - $routes->get('/', 'PluginController::generalSettings/$1/$2', [ + $routes->get('/', 'PluginController::view/$1/$2', [ + 'as' => 'plugins-view', + 'filter' => 'permission:plugins.manage', + ]); + $routes->get('settings', 'PluginController::generalSettings/$1/$2', [ 'as' => 'plugins-general-settings', 'filter' => 'permission:plugins.manage', ]); - $routes->post('/', 'PluginController::generalSettingsAction/$1/$2', [ + $routes->post('settings', 'PluginController::generalSettingsAction/$1/$2', [ 'as' => 'plugins-general-settings-action', 'filter' => 'permission:plugins.manage', ]); diff --git a/modules/Plugins/Controllers/PluginController.php b/modules/Plugins/Controllers/PluginController.php index 5507552d..908c1801 100644 --- a/modules/Plugins/Controllers/PluginController.php +++ b/modules/Plugins/Controllers/PluginController.php @@ -48,6 +48,22 @@ class PluginController extends BaseController ]); } + public function view(string $vendor, string $package): string + { + /** @var Plugins $plugins */ + $plugins = service('plugins'); + + $plugin = $plugins->getPlugin($vendor, $package); + + if ($plugin === null) { + throw PageNotFoundException::forPageNotFound(); + } + + return view('plugins/view', [ + 'plugin' => $plugin, + ]); + } + public function generalSettings(string $vendor, string $package): string { /** @var Plugins $plugins */ diff --git a/modules/Plugins/Core/BasePlugin.php b/modules/Plugins/Core/BasePlugin.php index ff31b96f..1ccbb3c5 100644 --- a/modules/Plugins/Core/BasePlugin.php +++ b/modules/Plugins/Core/BasePlugin.php @@ -8,6 +8,14 @@ use App\Entities\Episode; use App\Entities\Podcast; use App\Libraries\SimpleRSSElement; use CodeIgniter\HTTP\URI; +use League\CommonMark\Environment\Environment; +use League\CommonMark\Event\DocumentParsedEvent; +use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; +use League\CommonMark\Extension\GithubFlavoredMarkdownExtension; +use League\CommonMark\Extension\SmartPunct\SmartPunctExtension; +use League\CommonMark\MarkdownConverter; +use Modules\Plugins\ExternalImageProcessor; +use Modules\Plugins\ExternalLinkProcessor; use Modules\Plugins\Manifest\Manifest; use Modules\Plugins\Manifest\Settings; use Modules\Plugins\Manifest\SettingsField; @@ -27,6 +35,8 @@ abstract class BasePlugin implements PluginInterface protected Manifest $manifest; + protected string $readmeHTML; + public function __construct( protected string $vendor, protected string $package, @@ -55,6 +65,8 @@ abstract class BasePlugin implements PluginInterface $this->active = get_plugin_option($this->key, 'active') ?? false; $this->iconSrc = $this->loadIcon($directory . '/icon.svg'); + + $this->readmeHTML = $this->loadReadme($directory . '/README.md'); } /** @@ -182,6 +194,11 @@ abstract class BasePlugin implements PluginInterface return $description; } + final public function getReadmeHTML(): string + { + return $this->readmeHTML; + } + final protected function getOption(string $option): mixed { return get_plugin_option($this->key, $option); @@ -208,4 +225,38 @@ abstract class BasePlugin implements PluginInterface $encodedIcon ); } + + private function loadReadme(string $path): ?string + { + // TODO: cache readme + $readmeMD = @file_get_contents($path); + + if (! $readmeMD) { + return null; + } + + $environment = new Environment([ + 'html_input' => 'escape', + 'allow_unsafe_links' => false, + 'host' => 'hello', + ]); + $environment->addExtension(new CommonMarkCoreExtension()); + + $environment->addExtension(new GithubFlavoredMarkdownExtension()); + $environment->addExtension(new SmartPunctExtension()); + + $environment->addEventListener( + DocumentParsedEvent::class, + [new ExternalLinkProcessor($environment), 'onDocumentParsed'] + ); + $environment->addEventListener( + DocumentParsedEvent::class, + [new ExternalImageProcessor($environment), 'onDocumentParsed'] + ); + + $converter = new MarkdownConverter($environment); + + return $converter->convert($readmeMD) + ->getContent(); + } } diff --git a/modules/Plugins/ExternalImageProcessor.php b/modules/Plugins/ExternalImageProcessor.php new file mode 100644 index 00000000..946afb65 --- /dev/null +++ b/modules/Plugins/ExternalImageProcessor.php @@ -0,0 +1,53 @@ +environment = $environment; + } + + public function onDocumentParsed(DocumentParsedEvent $event): void + { + $document = $event->getDocument(); + $walker = $document->walker(); + while ($event = $walker->next()) { + $node = $event->getNode(); + + // Only stop at Link nodes when we first encounter them + if (! ($node instanceof Image) || ! $event->isEntering()) { + continue; + } + + $url = $node->getUrl(); + if ($this->isUrlExternal($url)) { + $node->detach(); + } + } + } + + private function isUrlExternal(string $url): bool + { + // Only look at http and https URLs + if (! preg_match('/^https?:\/\//', $url)) { + return false; + } + + $host = parse_url($url, PHP_URL_HOST); + + // TODO: load from environment's config + // return $host != $this->environment->getConfiguration()->get('host'); + return $host !== (new URI(base_url()))->getHost(); + } +} diff --git a/modules/Plugins/ExternalLinkProcessor.php b/modules/Plugins/ExternalLinkProcessor.php new file mode 100644 index 00000000..8b7c1ea4 --- /dev/null +++ b/modules/Plugins/ExternalLinkProcessor.php @@ -0,0 +1,54 @@ +environment = $environment; + } + + public function onDocumentParsed(DocumentParsedEvent $event): void + { + $document = $event->getDocument(); + $walker = $document->walker(); + while ($event = $walker->next()) { + $node = $event->getNode(); + + // Only stop at Link nodes when we first encounter them + if (! ($node instanceof Link) || ! $event->isEntering()) { + continue; + } + + $url = $node->getUrl(); + if ($this->isUrlExternal($url)) { + $node->data->append('attributes/target', '_blank'); + $node->data->append('attributes/rel', 'noopener noreferrer'); + } + } + } + + private function isUrlExternal(string $url): bool + { + // Only look at http and https URLs + if (! preg_match('/^https?:\/\//', $url)) { + return false; + } + + $host = parse_url($url, PHP_URL_HOST); + + // TODO: load from environment's config + // return $host != $this->environment->getConfiguration()->get('host'); + return $host !== (new URI(base_url()))->getHost(); + } +} diff --git a/themes/cp_admin/plugins/view.php b/themes/cp_admin/plugins/view.php new file mode 100644 index 00000000..c20b3d5e --- /dev/null +++ b/themes/cp_admin/plugins/view.php @@ -0,0 +1,16 @@ +extend('_layout') ?> + +section('title') ?> + +endSection() ?> + +section('pageTitle') ?> + +endSection() ?> + +section('content') ?> +
+ + getReadmeHTML() ?> +
+endSection() ?> \ No newline at end of file