1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
|
<?php
namespace Shaarli\Plugin;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Config\ConfigManager;
use Shaarli\Plugin\Exception\PluginFileNotFoundException;
use Shaarli\Plugin\Exception\PluginInvalidRouteException;
/**
* Class PluginManager
*
* Use to manage, load and execute plugins.
*/
class PluginManager
{
/**
* List of authorized plugins from configuration file.
*
* @var array $authorizedPlugins
*/
private $authorizedPlugins = [];
/**
* List of loaded plugins.
*
* @var array $loadedPlugins
*/
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.
*/
protected $conf;
/**
* @var array List of plugin errors.
*/
protected $errors;
/** @var callable[]|null Preloaded list of hook function for filterSearchEntry() */
protected $filterSearchEntryHooks = null;
/**
* Plugins subdirectory.
*
* @var string $PLUGINS_PATH
*/
public static $PLUGINS_PATH = 'plugins';
/**
* Plugins meta files extension.
*
* @var string $META_EXT
*/
public static $META_EXT = 'meta';
/**
* Constructor.
*
* @param ConfigManager $conf Configuration Manager instance.
*/
public function __construct(&$conf)
{
$this->conf = $conf;
$this->errors = [];
}
/**
* Load plugins listed in $authorizedPlugins.
*
* @param array $authorizedPlugins Names of plugin authorized to be loaded.
*
* @return void
*/
public function load($authorizedPlugins)
{
$this->authorizedPlugins = $authorizedPlugins;
$dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR);
$dirnames = array_map('basename', $dirs);
foreach ($this->authorizedPlugins as $plugin) {
$index = array_search($plugin, $dirnames);
// plugin authorized, but its folder isn't listed
if ($index === false) {
continue;
}
try {
$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]));
}
}
}
/**
* Execute all plugins registered hook.
*
* @param string $hook name of the hook to trigger.
* @param array $data list of data to manipulate passed by reference.
* @param array $params additional parameters such as page target.
*
* @return void
*/
public function executeHooks($hook, &$data, $params = [])
{
$metadataParameters = [
'target' => '_PAGE_',
'loggedin' => '_LOGGEDIN_',
'basePath' => '_BASE_PATH_',
'rootPath' => '_ROOT_PATH_',
'bookmarkService' => '_BOOKMARK_SERVICE_',
];
foreach ($metadataParameters as $parameter => $metaKey) {
if (array_key_exists($parameter, $params)) {
$data[$metaKey] = $params[$parameter];
}
}
foreach ($this->loadedPlugins as $plugin) {
$hookFunction = $this->buildHookName($hook, $plugin);
if (function_exists($hookFunction)) {
try {
$data = call_user_func($hookFunction, $data, $this->conf);
} catch (\Throwable $e) {
$error = $plugin . t(' [plugin incompatibility]: ') . $e->getMessage();
$this->errors = array_unique(array_merge($this->errors, [$error]));
}
}
}
foreach ($metadataParameters as $metaKey) {
unset($data[$metaKey]);
}
}
/**
* Load a single plugin from its files.
* Call the init function if it exists, and collect errors.
* Add them in $loadedPlugins if successful.
*
* @param string $dir plugin's directory.
* @param string $pluginName plugin's name.
*
* @return void
* @throws \Shaarli\Plugin\Exception\PluginFileNotFoundException - plugin files not found.
*/
private function loadPlugin($dir, $pluginName)
{
if (!is_dir($dir)) {
throw new PluginFileNotFoundException($pluginName);
}
$pluginFilePath = $dir . '/' . $pluginName . '.php';
if (!is_file($pluginFilePath)) {
throw new PluginFileNotFoundException($pluginName);
}
$conf = $this->conf;
include_once $pluginFilePath;
$initFunction = $pluginName . '_init';
if (function_exists($initFunction)) {
$errors = call_user_func($initFunction, $this->conf);
if (!empty($errors)) {
$this->errors = array_merge($this->errors, $errors);
}
}
$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;
}
/**
* Construct normalize hook name for a specific plugin.
*
* Format:
* hook_<plugin_name>_<hook_name>
*
* @param string $hook hook name.
* @param string $pluginName plugin name.
*
* @return string - plugin's hook name.
*/
public function buildHookName($hook, $pluginName)
{
return 'hook_' . $pluginName . '_' . $hook;
}
/**
* Retrieve plugins metadata from *.meta (INI) files into an array.
* Metadata contains:
* - plugin description [description]
* - parameters split with ';' [parameters]
*
* Respects plugins order from settings.
*
* @return array plugins metadata.
*/
public function getPluginsMeta()
{
$metaData = [];
$dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR | GLOB_MARK);
// Browse all plugin directories.
foreach ($dirs as $pluginDir) {
$plugin = basename($pluginDir);
$metaFile = $pluginDir . $plugin . '.' . self::$META_EXT;
if (!is_file($metaFile) || !is_readable($metaFile)) {
continue;
}
$metaData[$plugin] = parse_ini_file($metaFile);
$metaData[$plugin]['order'] = array_search($plugin, $this->authorizedPlugins);
if (isset($metaData[$plugin]['description'])) {
$metaData[$plugin]['description'] = t($metaData[$plugin]['description']);
}
// Read parameters and format them into an array.
if (isset($metaData[$plugin]['parameters'])) {
$params = explode(';', $metaData[$plugin]['parameters']);
} else {
$params = [];
}
$metaData[$plugin]['parameters'] = [];
foreach ($params as $param) {
if (empty($param)) {
continue;
}
$metaData[$plugin]['parameters'][$param]['value'] = '';
// Optional parameter description in parameter.PARAM_NAME=
if (isset($metaData[$plugin]['parameter.' . $param])) {
$metaData[$plugin]['parameters'][$param]['desc'] = t($metaData[$plugin]['parameter.' . $param]);
}
}
}
return $metaData;
}
/**
* @return array List of registered custom routes by plugins.
*/
public function getRegisteredRoutes(): array
{
return $this->registeredRoutes;
}
/**
* @return array List of registered filter_search_entry hooks
*/
public function getFilterSearchEntryHooks(): ?array
{
return $this->filterSearchEntryHooks;
}
/**
* Return the list of encountered errors.
*
* @return array List of errors (empty array if none exists).
*/
public function getErrors()
{
return $this->errors;
}
/**
* Apply additional filter on every search result of BookmarkFilter calling plugins hooks.
*
* @param Bookmark $bookmark To check.
* @param array $context Additional info about search context, depends on the search source.
*
* @return bool True if the result must be kept in search results, false otherwise.
*/
public function filterSearchEntry(Bookmark $bookmark, array $context): bool
{
if ($this->filterSearchEntryHooks === null) {
$this->loadFilterSearchEntryHooks();
}
if ($this->filterSearchEntryHooks === []) {
return true;
}
foreach ($this->filterSearchEntryHooks as $filterSearchEntryHook) {
if ($filterSearchEntryHook($bookmark, $context) === false) {
return false;
}
}
return true;
}
/**
* filterSearchEntry() method will be called for every search result,
* so for performances we preload existing functions to invoke them directly.
*/
protected function loadFilterSearchEntryHooks(): void
{
$this->filterSearchEntryHooks = [];
foreach ($this->loadedPlugins as $plugin) {
$hookFunction = $this->buildHookName('filter_search_entry', $plugin);
if (function_exists($hookFunction)) {
$this->filterSearchEntryHooks[] = $hookFunction;
}
}
}
/**
* 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;
}
}
|