aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorArthurHoaro <arthur@hoa.ro>2020-12-16 16:04:15 +0100
committerGitHub <noreply@github.com>2020-12-16 16:04:15 +0100
commitbd11879018416d2c5d87728bb0be6ee0cf54451a (patch)
treef3147ab9eef24ff430c131249166fe84d19a4b07
parent8f423eb11c6642d96b5144f56e4698652591ad6b (diff)
parenta6e9c08499f9f79dad88cb3ae9eacda0e0c34c96 (diff)
downloadShaarli-bd11879018416d2c5d87728bb0be6ee0cf54451a.tar.gz
Shaarli-bd11879018416d2c5d87728bb0be6ee0cf54451a.tar.zst
Shaarli-bd11879018416d2c5d87728bb0be6ee0cf54451a.zip
Merge pull request #1645 from ArthurHoaro/feature/plugin-register-route
Plugin system: allow plugins to provide custom routes
-rw-r--r--application/container/ContainerBuilder.php17
-rw-r--r--application/plugin/PluginManager.php64
-rw-r--r--application/plugin/exception/PluginInvalidRouteException.php26
-rw-r--r--doc/md/dev/Plugin-system.md25
-rw-r--r--index.php22
-rw-r--r--phpcs.xml1
-rw-r--r--plugins/demo_plugin/DemoPluginController.php24
-rw-r--r--plugins/demo_plugin/demo_plugin.php19
-rw-r--r--tests/PluginManagerTest.php39
-rw-r--r--tests/container/ContainerBuilderTest.php5
-rw-r--r--tests/plugins/test/test.php16
-rw-r--r--tests/plugins/test_route_invalid/test_route_invalid.php12
-rw-r--r--tpl/default/pluginscontent.html13
13 files changed, 270 insertions, 13 deletions
diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php
index f0234eca..6d69a880 100644
--- a/application/container/ContainerBuilder.php
+++ b/application/container/ContainerBuilder.php
@@ -50,6 +50,9 @@ class ContainerBuilder
50 /** @var LoginManager */ 50 /** @var LoginManager */
51 protected $login; 51 protected $login;
52 52
53 /** @var PluginManager */
54 protected $pluginManager;
55
53 /** @var LoggerInterface */ 56 /** @var LoggerInterface */
54 protected $logger; 57 protected $logger;
55 58
@@ -61,12 +64,14 @@ class ContainerBuilder
61 SessionManager $session, 64 SessionManager $session,
62 CookieManager $cookieManager, 65 CookieManager $cookieManager,
63 LoginManager $login, 66 LoginManager $login,
67 PluginManager $pluginManager,
64 LoggerInterface $logger 68 LoggerInterface $logger
65 ) { 69 ) {
66 $this->conf = $conf; 70 $this->conf = $conf;
67 $this->session = $session; 71 $this->session = $session;
68 $this->login = $login; 72 $this->login = $login;
69 $this->cookieManager = $cookieManager; 73 $this->cookieManager = $cookieManager;
74 $this->pluginManager = $pluginManager;
70 $this->logger = $logger; 75 $this->logger = $logger;
71 } 76 }
72 77
@@ -78,12 +83,10 @@ class ContainerBuilder
78 $container['sessionManager'] = $this->session; 83 $container['sessionManager'] = $this->session;
79 $container['cookieManager'] = $this->cookieManager; 84 $container['cookieManager'] = $this->cookieManager;
80 $container['loginManager'] = $this->login; 85 $container['loginManager'] = $this->login;
86 $container['pluginManager'] = $this->pluginManager;
81 $container['logger'] = $this->logger; 87 $container['logger'] = $this->logger;
82 $container['basePath'] = $this->basePath; 88 $container['basePath'] = $this->basePath;
83 89
84 $container['plugins'] = function (ShaarliContainer $container): PluginManager {
85 return new PluginManager($container->conf);
86 };
87 90
88 $container['history'] = function (ShaarliContainer $container): History { 91 $container['history'] = function (ShaarliContainer $container): History {
89 return new History($container->conf->get('resource.history')); 92 return new History($container->conf->get('resource.history'));
@@ -113,14 +116,6 @@ class ContainerBuilder
113 ); 116 );
114 }; 117 };
115 118
116 $container['pluginManager'] = function (ShaarliContainer $container): PluginManager {
117 $pluginManager = new PluginManager($container->conf);
118
119 $pluginManager->load($container->conf->get('general.enabled_plugins'));
120
121 return $pluginManager;
122 };
123
124 $container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory { 119 $container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory {
125 return new FormatterFactory( 120 return new FormatterFactory(
126 $container->conf, 121 $container->conf,
diff --git a/application/plugin/PluginManager.php b/application/plugin/PluginManager.php
index 3ea55728..7fc0cb04 100644
--- a/application/plugin/PluginManager.php
+++ b/application/plugin/PluginManager.php
@@ -4,6 +4,7 @@ namespace Shaarli\Plugin;
4 4
5use Shaarli\Config\ConfigManager; 5use Shaarli\Config\ConfigManager;
6use Shaarli\Plugin\Exception\PluginFileNotFoundException; 6use Shaarli\Plugin\Exception\PluginFileNotFoundException;
7use Shaarli\Plugin\Exception\PluginInvalidRouteException;
7 8
8/** 9/**
9 * Class PluginManager 10 * Class PluginManager
@@ -26,6 +27,14 @@ class PluginManager
26 */ 27 */
27 private $loadedPlugins = []; 28 private $loadedPlugins = [];
28 29
30 /** @var array List of registered routes. Contains keys:
31 * - `method`: HTTP method, GET/POST/PUT/PATCH/DELETE
32 * - `route` (path): without prefix, e.g. `/up/{variable}`
33 * It will be later prefixed by `/plugin/<plugin name>/`.
34 * - `callable` string, function name or FQN class's method, e.g. `demo_plugin_custom_controller`.
35 */
36 protected $registeredRoutes = [];
37
29 /** 38 /**
30 * @var ConfigManager Configuration Manager instance. 39 * @var ConfigManager Configuration Manager instance.
31 */ 40 */
@@ -86,6 +95,9 @@ class PluginManager
86 $this->loadPlugin($dirs[$index], $plugin); 95 $this->loadPlugin($dirs[$index], $plugin);
87 } catch (PluginFileNotFoundException $e) { 96 } catch (PluginFileNotFoundException $e) {
88 error_log($e->getMessage()); 97 error_log($e->getMessage());
98 } catch (\Throwable $e) {
99 $error = $plugin . t(' [plugin incompatibility]: ') . $e->getMessage();
100 $this->errors = array_unique(array_merge($this->errors, [$error]));
89 } 101 }
90 } 102 }
91 } 103 }
@@ -166,6 +178,22 @@ class PluginManager
166 } 178 }
167 } 179 }
168 180
181 $registerRouteFunction = $pluginName . '_register_routes';
182 $routes = null;
183 if (function_exists($registerRouteFunction)) {
184 $routes = call_user_func($registerRouteFunction);
185 }
186
187 if ($routes !== null) {
188 foreach ($routes as $route) {
189 if (static::validateRouteRegistration($route)) {
190 $this->registeredRoutes[$pluginName][] = $route;
191 } else {
192 throw new PluginInvalidRouteException($pluginName);
193 }
194 }
195 }
196
169 $this->loadedPlugins[] = $pluginName; 197 $this->loadedPlugins[] = $pluginName;
170 } 198 }
171 199
@@ -238,6 +266,14 @@ class PluginManager
238 } 266 }
239 267
240 /** 268 /**
269 * @return array List of registered custom routes by plugins.
270 */
271 public function getRegisteredRoutes(): array
272 {
273 return $this->registeredRoutes;
274 }
275
276 /**
241 * Return the list of encountered errors. 277 * Return the list of encountered errors.
242 * 278 *
243 * @return array List of errors (empty array if none exists). 279 * @return array List of errors (empty array if none exists).
@@ -246,4 +282,32 @@ class PluginManager
246 { 282 {
247 return $this->errors; 283 return $this->errors;
248 } 284 }
285
286 /**
287 * Checks whether provided input is valid to register a new route.
288 * It must contain keys `method`, `route`, `callable` (all strings).
289 *
290 * @param string[] $input
291 *
292 * @return bool
293 */
294 protected static function validateRouteRegistration(array $input): bool
295 {
296 if (
297 !array_key_exists('method', $input)
298 || !in_array(strtoupper($input['method']), ['GET', 'PUT', 'PATCH', 'POST', 'DELETE'])
299 ) {
300 return false;
301 }
302
303 if (!array_key_exists('route', $input) || !preg_match('#^[a-z\d/\.\-_]+$#', $input['route'])) {
304 return false;
305 }
306
307 if (!array_key_exists('callable', $input)) {
308 return false;
309 }
310
311 return true;
312 }
249} 313}
diff --git a/application/plugin/exception/PluginInvalidRouteException.php b/application/plugin/exception/PluginInvalidRouteException.php
new file mode 100644
index 00000000..6ba9bc43
--- /dev/null
+++ b/application/plugin/exception/PluginInvalidRouteException.php
@@ -0,0 +1,26 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Plugin\Exception;
6
7use Exception;
8
9/**
10 * Class PluginFileNotFoundException
11 *
12 * Raise when plugin files can't be found.
13 */
14class PluginInvalidRouteException extends Exception
15{
16 /**
17 * Construct exception with plugin name.
18 * Generate message.
19 *
20 * @param string $pluginName name of the plugin not found
21 */
22 public function __construct()
23 {
24 $this->message = 'trying to register invalid route.';
25 }
26}
diff --git a/doc/md/dev/Plugin-system.md b/doc/md/dev/Plugin-system.md
index f09fadc2..79654011 100644
--- a/doc/md/dev/Plugin-system.md
+++ b/doc/md/dev/Plugin-system.md
@@ -139,6 +139,31 @@ Each file contain two keys:
139 139
140> Note: In PHP, `parse_ini_file()` seems to want strings to be between by quotes `"` in the ini file. 140> Note: In PHP, `parse_ini_file()` seems to want strings to be between by quotes `"` in the ini file.
141 141
142### Register plugin's routes
143
144Shaarli lets you register custom Slim routes for your plugin.
145
146To register a route, the plugin must include a function called `function <plugin_name>_register_routes(): array`.
147
148This method must return an array of routes, each entry must contain the following keys:
149
150 - `method`: HTTP method, `GET/POST/PUT/PATCH/DELETE`
151 - `route` (path): without prefix, e.g. `/up/{variable}`
152 It will be later prefixed by `/plugin/<plugin name>/`.
153 - `callable` string, function name or FQN class's method to execute, e.g. `demo_plugin_custom_controller`.
154
155Callable functions or methods must have `Slim\Http\Request` and `Slim\Http\Response` parameters
156and return a `Slim\Http\Response`. We recommend creating a dedicated class and extend either
157`ShaarliVisitorController` or `ShaarliAdminController` to use helper functions they provide.
158
159A dedicated plugin template is available for rendering content: `pluginscontent.html` using `content` placeholder.
160
161> **Warning**: plugins are not able to use RainTPL template engine for their content due to technical restrictions.
162> RainTPL does not allow to register multiple template folders, so all HTML rendering must be done within plugin
163> custom controller.
164
165Check out the `demo_plugin` for a live example: `GET <shaarli_url>/plugin/demo_plugin/custom`.
166
142### Understanding relative paths 167### Understanding relative paths
143 168
144Because Shaarli is a self-hosted tool, an instance can either be installed at the root directory, or under a subfolder. 169Because Shaarli is a self-hosted tool, an instance can either be installed at the root directory, or under a subfolder.
diff --git a/index.php b/index.php
index 1eb7659a..862c53ef 100644
--- a/index.php
+++ b/index.php
@@ -31,6 +31,7 @@ use Psr\Log\LogLevel;
31use Shaarli\Config\ConfigManager; 31use Shaarli\Config\ConfigManager;
32use Shaarli\Container\ContainerBuilder; 32use Shaarli\Container\ContainerBuilder;
33use Shaarli\Languages; 33use Shaarli\Languages;
34use Shaarli\Plugin\PluginManager;
34use Shaarli\Security\BanManager; 35use Shaarli\Security\BanManager;
35use Shaarli\Security\CookieManager; 36use Shaarli\Security\CookieManager;
36use Shaarli\Security\LoginManager; 37use Shaarli\Security\LoginManager;
@@ -87,7 +88,17 @@ date_default_timezone_set($conf->get('general.timezone', 'UTC'));
87 88
88$loginManager->checkLoginState(client_ip_id($_SERVER)); 89$loginManager->checkLoginState(client_ip_id($_SERVER));
89 90
90$containerBuilder = new ContainerBuilder($conf, $sessionManager, $cookieManager, $loginManager, $logger); 91$pluginManager = new PluginManager($conf);
92$pluginManager->load($conf->get('general.enabled_plugins', []));
93
94$containerBuilder = new ContainerBuilder(
95 $conf,
96 $sessionManager,
97 $cookieManager,
98 $loginManager,
99 $pluginManager,
100 $logger
101);
91$container = $containerBuilder->build(); 102$container = $containerBuilder->build();
92$app = new App($container); 103$app = new App($container);
93 104
@@ -154,6 +165,15 @@ $app->group('/admin', function () {
154 $this->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility'); 165 $this->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility');
155})->add('\Shaarli\Front\ShaarliAdminMiddleware'); 166})->add('\Shaarli\Front\ShaarliAdminMiddleware');
156 167
168$app->group('/plugin', function () use ($pluginManager) {
169 foreach ($pluginManager->getRegisteredRoutes() as $pluginName => $routes) {
170 $this->group('/' . $pluginName, function () use ($routes) {
171 foreach ($routes as $route) {
172 $this->{strtolower($route['method'])}('/' . ltrim($route['route'], '/'), $route['callable']);
173 }
174 });
175 }
176})->add('\Shaarli\Front\ShaarliMiddleware');
157 177
158// REST API routes 178// REST API routes
159$app->group('/api/v1', function () { 179$app->group('/api/v1', function () {
diff --git a/phpcs.xml b/phpcs.xml
index c559e35d..9bdc8720 100644
--- a/phpcs.xml
+++ b/phpcs.xml
@@ -18,5 +18,6 @@
18 <rule ref="PSR1.Files.SideEffects.FoundWithSymbols"> 18 <rule ref="PSR1.Files.SideEffects.FoundWithSymbols">
19 <!-- index.php bootstraps everything, so yes mixed symbols with side effects --> 19 <!-- index.php bootstraps everything, so yes mixed symbols with side effects -->
20 <exclude-pattern>index.php</exclude-pattern> 20 <exclude-pattern>index.php</exclude-pattern>
21 <exclude-pattern>plugins/*</exclude-pattern>
21 </rule> 22 </rule>
22</ruleset> 23</ruleset>
diff --git a/plugins/demo_plugin/DemoPluginController.php b/plugins/demo_plugin/DemoPluginController.php
new file mode 100644
index 00000000..b8ace9c8
--- /dev/null
+++ b/plugins/demo_plugin/DemoPluginController.php
@@ -0,0 +1,24 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\DemoPlugin;
6
7use Shaarli\Front\Controller\Admin\ShaarliAdminController;
8use Slim\Http\Request;
9use Slim\Http\Response;
10
11class DemoPluginController extends ShaarliAdminController
12{
13 public function index(Request $request, Response $response): Response
14 {
15 $this->assignView(
16 'content',
17 '<div class="center">' .
18 'This is a demo page. I have access to Shaarli container, so I\'m free to do whatever I want here.' .
19 '</div>'
20 );
21
22 return $response->write($this->render('pluginscontent'));
23 }
24}
diff --git a/plugins/demo_plugin/demo_plugin.php b/plugins/demo_plugin/demo_plugin.php
index 22d27b68..15cfc2c5 100644
--- a/plugins/demo_plugin/demo_plugin.php
+++ b/plugins/demo_plugin/demo_plugin.php
@@ -7,6 +7,8 @@
7 * Can be used by plugin developers to make their own plugin. 7 * Can be used by plugin developers to make their own plugin.
8 */ 8 */
9 9
10require_once __DIR__ . '/DemoPluginController.php';
11
10/* 12/*
11 * RENDER HEADER, INCLUDES, FOOTER 13 * RENDER HEADER, INCLUDES, FOOTER
12 * 14 *
@@ -60,6 +62,17 @@ function demo_plugin_init($conf)
60 return $errors; 62 return $errors;
61} 63}
62 64
65function demo_plugin_register_routes(): array
66{
67 return [
68 [
69 'method' => 'GET',
70 'route' => '/custom',
71 'callable' => 'Shaarli\DemoPlugin\DemoPluginController:index',
72 ],
73 ];
74}
75
63/** 76/**
64 * Hook render_header. 77 * Hook render_header.
65 * Executed on every page render. 78 * Executed on every page render.
@@ -304,7 +317,11 @@ function hook_demo_plugin_render_editlink($data)
304function hook_demo_plugin_render_tools($data) 317function hook_demo_plugin_render_tools($data)
305{ 318{
306 // field_plugin 319 // field_plugin
307 $data['tools_plugin'][] = 'tools_plugin'; 320 $data['tools_plugin'][] = '<div class="tools-item">
321 <a href="' . $data['_BASE_PATH_'] . '/plugin/demo_plugin/custom">
322 <span class="pure-button pure-u-lg-2-3 pure-u-3-4">Demo Plugin Custom Route</span>
323 </a>
324 </div>';
308 325
309 return $data; 326 return $data;
310} 327}
diff --git a/tests/PluginManagerTest.php b/tests/PluginManagerTest.php
index efef5e87..8947f679 100644
--- a/tests/PluginManagerTest.php
+++ b/tests/PluginManagerTest.php
@@ -120,4 +120,43 @@ class PluginManagerTest extends \Shaarli\TestCase
120 $this->assertEquals('test plugin', $meta[self::$pluginName]['description']); 120 $this->assertEquals('test plugin', $meta[self::$pluginName]['description']);
121 $this->assertEquals($expectedParameters, $meta[self::$pluginName]['parameters']); 121 $this->assertEquals($expectedParameters, $meta[self::$pluginName]['parameters']);
122 } 122 }
123
124 /**
125 * Test plugin custom routes - note that there is no check on callable functions
126 */
127 public function testRegisteredRoutes(): void
128 {
129 PluginManager::$PLUGINS_PATH = self::$pluginPath;
130 $this->pluginManager->load([self::$pluginName]);
131
132 $expectedParameters = [
133 [
134 'method' => 'GET',
135 'route' => '/test',
136 'callable' => 'getFunction',
137 ],
138 [
139 'method' => 'POST',
140 'route' => '/custom',
141 'callable' => 'postFunction',
142 ],
143 ];
144 $meta = $this->pluginManager->getRegisteredRoutes();
145 static::assertSame($expectedParameters, $meta[self::$pluginName]);
146 }
147
148 /**
149 * Test plugin custom routes with invalid route
150 */
151 public function testRegisteredRoutesInvalid(): void
152 {
153 $plugin = 'test_route_invalid';
154 $this->pluginManager->load([$plugin]);
155
156 $meta = $this->pluginManager->getRegisteredRoutes();
157 static::assertSame([], $meta);
158
159 $errors = $this->pluginManager->getErrors();
160 static::assertSame(['test_route_invalid [plugin incompatibility]: trying to register invalid route.'], $errors);
161 }
123} 162}
diff --git a/tests/container/ContainerBuilderTest.php b/tests/container/ContainerBuilderTest.php
index 3d43c344..04d4ef01 100644
--- a/tests/container/ContainerBuilderTest.php
+++ b/tests/container/ContainerBuilderTest.php
@@ -43,11 +43,15 @@ class ContainerBuilderTest extends TestCase
43 /** @var CookieManager */ 43 /** @var CookieManager */
44 protected $cookieManager; 44 protected $cookieManager;
45 45
46 /** @var PluginManager */
47 protected $pluginManager;
48
46 public function setUp(): void 49 public function setUp(): void
47 { 50 {
48 $this->conf = new ConfigManager('tests/utils/config/configJson'); 51 $this->conf = new ConfigManager('tests/utils/config/configJson');
49 $this->sessionManager = $this->createMock(SessionManager::class); 52 $this->sessionManager = $this->createMock(SessionManager::class);
50 $this->cookieManager = $this->createMock(CookieManager::class); 53 $this->cookieManager = $this->createMock(CookieManager::class);
54 $this->pluginManager = $this->createMock(PluginManager::class);
51 55
52 $this->loginManager = $this->createMock(LoginManager::class); 56 $this->loginManager = $this->createMock(LoginManager::class);
53 $this->loginManager->method('isLoggedIn')->willReturn(true); 57 $this->loginManager->method('isLoggedIn')->willReturn(true);
@@ -57,6 +61,7 @@ class ContainerBuilderTest extends TestCase
57 $this->sessionManager, 61 $this->sessionManager,
58 $this->cookieManager, 62 $this->cookieManager,
59 $this->loginManager, 63 $this->loginManager,
64 $this->pluginManager,
60 $this->createMock(LoggerInterface::class) 65 $this->createMock(LoggerInterface::class)
61 ); 66 );
62 } 67 }
diff --git a/tests/plugins/test/test.php b/tests/plugins/test/test.php
index 03be4f4e..34cd339e 100644
--- a/tests/plugins/test/test.php
+++ b/tests/plugins/test/test.php
@@ -27,3 +27,19 @@ function hook_test_error()
27{ 27{
28 new Unknown(); 28 new Unknown();
29} 29}
30
31function test_register_routes(): array
32{
33 return [
34 [
35 'method' => 'GET',
36 'route' => '/test',
37 'callable' => 'getFunction',
38 ],
39 [
40 'method' => 'POST',
41 'route' => '/custom',
42 'callable' => 'postFunction',
43 ],
44 ];
45}
diff --git a/tests/plugins/test_route_invalid/test_route_invalid.php b/tests/plugins/test_route_invalid/test_route_invalid.php
new file mode 100644
index 00000000..0c5a5101
--- /dev/null
+++ b/tests/plugins/test_route_invalid/test_route_invalid.php
@@ -0,0 +1,12 @@
1<?php
2
3function test_route_invalid_register_routes(): array
4{
5 return [
6 [
7 'method' => 'GET',
8 'route' => 'not a route',
9 'callable' => 'getFunction',
10 ],
11 ];
12}
diff --git a/tpl/default/pluginscontent.html b/tpl/default/pluginscontent.html
new file mode 100644
index 00000000..1e4f6b80
--- /dev/null
+++ b/tpl/default/pluginscontent.html
@@ -0,0 +1,13 @@
1<!DOCTYPE html>
2<html{if="$language !== 'auto'"} lang="{$language}"{/if}>
3<head>
4 {include="includes"}
5</head>
6<body>
7 {include="page.header"}
8
9 {$content}
10
11 {include="page.footer"}
12</body>
13</html>