/** @var LoginManager */
protected $login;
+ /** @var PluginManager */
+ protected $pluginManager;
+
/** @var LoggerInterface */
protected $logger;
SessionManager $session,
CookieManager $cookieManager,
LoginManager $login,
+ PluginManager $pluginManager,
LoggerInterface $logger
) {
$this->conf = $conf;
$this->session = $session;
$this->login = $login;
$this->cookieManager = $cookieManager;
+ $this->pluginManager = $pluginManager;
$this->logger = $logger;
}
$container['sessionManager'] = $this->session;
$container['cookieManager'] = $this->cookieManager;
$container['loginManager'] = $this->login;
+ $container['pluginManager'] = $this->pluginManager;
$container['logger'] = $this->logger;
$container['basePath'] = $this->basePath;
- $container['plugins'] = function (ShaarliContainer $container): PluginManager {
- return new PluginManager($container->conf);
- };
$container['history'] = function (ShaarliContainer $container): History {
return new History($container->conf->get('resource.history'));
);
};
- $container['pluginManager'] = function (ShaarliContainer $container): PluginManager {
- $pluginManager = new PluginManager($container->conf);
-
- $pluginManager->load($container->conf->get('general.enabled_plugins'));
-
- return $pluginManager;
- };
-
$container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory {
return new FormatterFactory(
$container->conf,
use Shaarli\Config\ConfigManager;
use Shaarli\Plugin\Exception\PluginFileNotFoundException;
+use Shaarli\Plugin\Exception\PluginInvalidRouteException;
/**
* Class PluginManager
*/
private $loadedPlugins = [];
+ /** @var array List of registered routes. Contains keys:
+ * - `method`: HTTP method, GET/POST/PUT/PATCH/DELETE
+ * - `route` (path): without prefix, e.g. `/up/{variable}`
+ * It will be later prefixed by `/plugin/<plugin name>/`.
+ * - `callable` string, function name or FQN class's method, e.g. `demo_plugin_custom_controller`.
+ */
+ protected $registeredRoutes = [];
+
/**
* @var ConfigManager Configuration Manager instance.
*/
$this->loadPlugin($dirs[$index], $plugin);
} catch (PluginFileNotFoundException $e) {
error_log($e->getMessage());
+ } catch (\Throwable $e) {
+ $error = $plugin . t(' [plugin incompatibility]: ') . $e->getMessage();
+ $this->errors = array_unique(array_merge($this->errors, [$error]));
}
}
}
}
}
+ $registerRouteFunction = $pluginName . '_register_routes';
+ $routes = null;
+ if (function_exists($registerRouteFunction)) {
+ $routes = call_user_func($registerRouteFunction);
+ }
+
+ if ($routes !== null) {
+ foreach ($routes as $route) {
+ if (static::validateRouteRegistration($route)) {
+ $this->registeredRoutes[$pluginName][] = $route;
+ } else {
+ throw new PluginInvalidRouteException($pluginName);
+ }
+ }
+ }
+
$this->loadedPlugins[] = $pluginName;
}
return $metaData;
}
+ /**
+ * @return array List of registered custom routes by plugins.
+ */
+ public function getRegisteredRoutes(): array
+ {
+ return $this->registeredRoutes;
+ }
+
/**
* Return the list of encountered errors.
*
{
return $this->errors;
}
+
+ /**
+ * Checks whether provided input is valid to register a new route.
+ * It must contain keys `method`, `route`, `callable` (all strings).
+ *
+ * @param string[] $input
+ *
+ * @return bool
+ */
+ protected static function validateRouteRegistration(array $input): bool
+ {
+ if (
+ !array_key_exists('method', $input)
+ || !in_array(strtoupper($input['method']), ['GET', 'PUT', 'PATCH', 'POST', 'DELETE'])
+ ) {
+ return false;
+ }
+
+ if (!array_key_exists('route', $input) || !preg_match('#^[a-z\d/\.\-_]+$#', $input['route'])) {
+ return false;
+ }
+
+ if (!array_key_exists('callable', $input)) {
+ return false;
+ }
+
+ return true;
+ }
}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Plugin\Exception;
+
+use Exception;
+
+/**
+ * Class PluginFileNotFoundException
+ *
+ * Raise when plugin files can't be found.
+ */
+class PluginInvalidRouteException extends Exception
+{
+ /**
+ * Construct exception with plugin name.
+ * Generate message.
+ *
+ * @param string $pluginName name of the plugin not found
+ */
+ public function __construct()
+ {
+ $this->message = 'trying to register invalid route.';
+ }
+}
> Note: In PHP, `parse_ini_file()` seems to want strings to be between by quotes `"` in the ini file.
+### Register plugin's routes
+
+Shaarli lets you register custom Slim routes for your plugin.
+
+To register a route, the plugin must include a function called `function <plugin_name>_register_routes(): array`.
+
+This method must return an array of routes, each entry must contain the following keys:
+
+ - `method`: HTTP method, `GET/POST/PUT/PATCH/DELETE`
+ - `route` (path): without prefix, e.g. `/up/{variable}`
+ It will be later prefixed by `/plugin/<plugin name>/`.
+ - `callable` string, function name or FQN class's method to execute, e.g. `demo_plugin_custom_controller`.
+
+Callable functions or methods must have `Slim\Http\Request` and `Slim\Http\Response` parameters
+and return a `Slim\Http\Response`. We recommend creating a dedicated class and extend either
+`ShaarliVisitorController` or `ShaarliAdminController` to use helper functions they provide.
+
+A dedicated plugin template is available for rendering content: `pluginscontent.html` using `content` placeholder.
+
+> **Warning**: plugins are not able to use RainTPL template engine for their content due to technical restrictions.
+> RainTPL does not allow to register multiple template folders, so all HTML rendering must be done within plugin
+> custom controller.
+
+Check out the `demo_plugin` for a live example: `GET <shaarli_url>/plugin/demo_plugin/custom`.
+
### Understanding relative paths
Because Shaarli is a self-hosted tool, an instance can either be installed at the root directory, or under a subfolder.
use Shaarli\Config\ConfigManager;
use Shaarli\Container\ContainerBuilder;
use Shaarli\Languages;
+use Shaarli\Plugin\PluginManager;
use Shaarli\Security\BanManager;
use Shaarli\Security\CookieManager;
use Shaarli\Security\LoginManager;
$loginManager->checkLoginState(client_ip_id($_SERVER));
-$containerBuilder = new ContainerBuilder($conf, $sessionManager, $cookieManager, $loginManager, $logger);
+$pluginManager = new PluginManager($conf);
+$pluginManager->load($conf->get('general.enabled_plugins', []));
+
+$containerBuilder = new ContainerBuilder(
+ $conf,
+ $sessionManager,
+ $cookieManager,
+ $loginManager,
+ $pluginManager,
+ $logger
+);
$container = $containerBuilder->build();
$app = new App($container);
$this->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility');
})->add('\Shaarli\Front\ShaarliAdminMiddleware');
+$app->group('/plugin', function () use ($pluginManager) {
+ foreach ($pluginManager->getRegisteredRoutes() as $pluginName => $routes) {
+ $this->group('/' . $pluginName, function () use ($routes) {
+ foreach ($routes as $route) {
+ $this->{strtolower($route['method'])}('/' . ltrim($route['route'], '/'), $route['callable']);
+ }
+ });
+ }
+})->add('\Shaarli\Front\ShaarliMiddleware');
// REST API routes
$app->group('/api/v1', function () {
<rule ref="PSR1.Files.SideEffects.FoundWithSymbols">
<!-- index.php bootstraps everything, so yes mixed symbols with side effects -->
<exclude-pattern>index.php</exclude-pattern>
+ <exclude-pattern>plugins/*</exclude-pattern>
</rule>
</ruleset>
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\DemoPlugin;
+
+use Shaarli\Front\Controller\Admin\ShaarliAdminController;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class DemoPluginController extends ShaarliAdminController
+{
+ public function index(Request $request, Response $response): Response
+ {
+ $this->assignView(
+ 'content',
+ '<div class="center">' .
+ 'This is a demo page. I have access to Shaarli container, so I\'m free to do whatever I want here.' .
+ '</div>'
+ );
+
+ return $response->write($this->render('pluginscontent'));
+ }
+}
* Can be used by plugin developers to make their own plugin.
*/
+require_once __DIR__ . '/DemoPluginController.php';
+
/*
* RENDER HEADER, INCLUDES, FOOTER
*
return $errors;
}
+function demo_plugin_register_routes(): array
+{
+ return [
+ [
+ 'method' => 'GET',
+ 'route' => '/custom',
+ 'callable' => 'Shaarli\DemoPlugin\DemoPluginController:index',
+ ],
+ ];
+}
+
/**
* Hook render_header.
* Executed on every page render.
function hook_demo_plugin_render_tools($data)
{
// field_plugin
- $data['tools_plugin'][] = 'tools_plugin';
+ $data['tools_plugin'][] = '<div class="tools-item">
+ <a href="' . $data['_BASE_PATH_'] . '/plugin/demo_plugin/custom">
+ <span class="pure-button pure-u-lg-2-3 pure-u-3-4">Demo Plugin Custom Route</span>
+ </a>
+ </div>';
return $data;
}
$this->assertEquals('test plugin', $meta[self::$pluginName]['description']);
$this->assertEquals($expectedParameters, $meta[self::$pluginName]['parameters']);
}
+
+ /**
+ * Test plugin custom routes - note that there is no check on callable functions
+ */
+ public function testRegisteredRoutes(): void
+ {
+ PluginManager::$PLUGINS_PATH = self::$pluginPath;
+ $this->pluginManager->load([self::$pluginName]);
+
+ $expectedParameters = [
+ [
+ 'method' => 'GET',
+ 'route' => '/test',
+ 'callable' => 'getFunction',
+ ],
+ [
+ 'method' => 'POST',
+ 'route' => '/custom',
+ 'callable' => 'postFunction',
+ ],
+ ];
+ $meta = $this->pluginManager->getRegisteredRoutes();
+ static::assertSame($expectedParameters, $meta[self::$pluginName]);
+ }
+
+ /**
+ * Test plugin custom routes with invalid route
+ */
+ public function testRegisteredRoutesInvalid(): void
+ {
+ $plugin = 'test_route_invalid';
+ $this->pluginManager->load([$plugin]);
+
+ $meta = $this->pluginManager->getRegisteredRoutes();
+ static::assertSame([], $meta);
+
+ $errors = $this->pluginManager->getErrors();
+ static::assertSame(['test_route_invalid [plugin incompatibility]: trying to register invalid route.'], $errors);
+ }
}
/** @var CookieManager */
protected $cookieManager;
+ /** @var PluginManager */
+ protected $pluginManager;
+
public function setUp(): void
{
$this->conf = new ConfigManager('tests/utils/config/configJson');
$this->sessionManager = $this->createMock(SessionManager::class);
$this->cookieManager = $this->createMock(CookieManager::class);
+ $this->pluginManager = $this->createMock(PluginManager::class);
$this->loginManager = $this->createMock(LoginManager::class);
$this->loginManager->method('isLoggedIn')->willReturn(true);
$this->sessionManager,
$this->cookieManager,
$this->loginManager,
+ $this->pluginManager,
$this->createMock(LoggerInterface::class)
);
}
{
new Unknown();
}
+
+function test_register_routes(): array
+{
+ return [
+ [
+ 'method' => 'GET',
+ 'route' => '/test',
+ 'callable' => 'getFunction',
+ ],
+ [
+ 'method' => 'POST',
+ 'route' => '/custom',
+ 'callable' => 'postFunction',
+ ],
+ ];
+}
--- /dev/null
+<?php
+
+function test_route_invalid_register_routes(): array
+{
+ return [
+ [
+ 'method' => 'GET',
+ 'route' => 'not a route',
+ 'callable' => 'getFunction',
+ ],
+ ];
+}
--- /dev/null
+<!DOCTYPE html>
+<html{if="$language !== 'auto'"} lang="{$language}"{/if}>
+<head>
+ {include="includes"}
+</head>
+<body>
+ {include="page.header"}
+
+ {$content}
+
+ {include="page.footer"}
+</body>
+</html>