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