]> git.immae.eu Git - github/shaarli/Shaarli.git/blame - application/plugin/PluginManager.php
Plugin system: allow plugins to provide custom routes
[github/shaarli/Shaarli.git] / application / plugin / PluginManager.php
CommitLineData
6fc14d53 1<?php
53054b2b 2
e1850388
V
3namespace Shaarli\Plugin;
4
5use Shaarli\Config\ConfigManager;
6use Shaarli\Plugin\Exception\PluginFileNotFoundException;
a6e9c084 7use Shaarli\Plugin\Exception\PluginInvalidRouteException;
6fc14d53
A
8
9/**
10 * Class PluginManager
11 *
12 * Use to manage, load and execute plugins.
6fc14d53
A
13 */
14class PluginManager
15{
6fc14d53
A
16 /**
17 * List of authorized plugins from configuration file.
e1850388 18 *
6fc14d53
A
19 * @var array $authorizedPlugins
20 */
1b8620b1 21 private $authorizedPlugins = [];
6fc14d53
A
22
23 /**
24 * List of loaded plugins.
e1850388 25 *
6fc14d53
A
26 * @var array $loadedPlugins
27 */
53054b2b 28 private $loadedPlugins = [];
6fc14d53 29
a6e9c084
A
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
51def0d8
A
38 /**
39 * @var ConfigManager Configuration Manager instance.
40 */
41 protected $conf;
42
7fde6de1
A
43 /**
44 * @var array List of plugin errors.
45 */
46 protected $errors;
47
6fc14d53
A
48 /**
49 * Plugins subdirectory.
e1850388 50 *
6fc14d53
A
51 * @var string $PLUGINS_PATH
52 */
53 public static $PLUGINS_PATH = 'plugins';
54
dea0ba28
A
55 /**
56 * Plugins meta files extension.
e1850388 57 *
dea0ba28
A
58 * @var string $META_EXT
59 */
60 public static $META_EXT = 'meta';
61
6fc14d53 62 /**
51def0d8 63 * Constructor.
6fc14d53 64 *
51def0d8 65 * @param ConfigManager $conf Configuration Manager instance.
6fc14d53 66 */
51def0d8 67 public function __construct(&$conf)
6fc14d53 68 {
51def0d8 69 $this->conf = $conf;
53054b2b 70 $this->errors = [];
6fc14d53
A
71 }
72
73 /**
74 * Load plugins listed in $authorizedPlugins.
75 *
76 * @param array $authorizedPlugins Names of plugin authorized to be loaded.
77 *
78 * @return void
79 */
80 public function load($authorizedPlugins)
81 {
82 $this->authorizedPlugins = $authorizedPlugins;
83
84 $dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR);
85 $dirnames = array_map('basename', $dirs);
86 foreach ($this->authorizedPlugins as $plugin) {
87 $index = array_search($plugin, $dirnames);
88
89 // plugin authorized, but its folder isn't listed
90 if ($index === false) {
91 continue;
92 }
93
94 try {
95 $this->loadPlugin($dirs[$index], $plugin);
f211e417 96 } catch (PluginFileNotFoundException $e) {
6fc14d53 97 error_log($e->getMessage());
a6e9c084
A
98 } catch (\Throwable $e) {
99 $error = $plugin . t(' [plugin incompatibility]: ') . $e->getMessage();
100 $this->errors = array_unique(array_merge($this->errors, [$error]));
6fc14d53
A
101 }
102 }
103 }
104
105 /**
106 * Execute all plugins registered hook.
107 *
e1850388
V
108 * @param string $hook name of the hook to trigger.
109 * @param array $data list of data to manipulate passed by reference.
110 * @param array $params additional parameters such as page target.
567967fd 111 *
6fc14d53
A
112 * @return void
113 */
53054b2b 114 public function executeHooks($hook, &$data, $params = [])
6fc14d53 115 {
4ff703e3
A
116 $metadataParameters = [
117 'target' => '_PAGE_',
118 'loggedin' => '_LOGGEDIN_',
119 'basePath' => '_BASE_PATH_',
3adbdc2a 120 'rootPath' => '_ROOT_PATH_',
4ff703e3
A
121 'bookmarkService' => '_BOOKMARK_SERVICE_',
122 ];
123
124 foreach ($metadataParameters as $parameter => $metaKey) {
125 if (array_key_exists($parameter, $params)) {
126 $data[$metaKey] = $params[$parameter];
127 }
80b708a8
A
128 }
129
6fc14d53
A
130 foreach ($this->loadedPlugins as $plugin) {
131 $hookFunction = $this->buildHookName($hook, $plugin);
132
133 if (function_exists($hookFunction)) {
7e3dc0ba
A
134 try {
135 $data = call_user_func($hookFunction, $data, $this->conf);
136 } catch (\Throwable $e) {
137 $error = $plugin . t(' [plugin incompatibility]: ') . $e->getMessage();
138 $this->errors = array_unique(array_merge($this->errors, [$error]));
139 }
6fc14d53
A
140 }
141 }
4ff703e3
A
142
143 foreach ($metadataParameters as $metaKey) {
144 unset($data[$metaKey]);
145 }
6fc14d53
A
146 }
147
148 /**
149 * Load a single plugin from its files.
7fde6de1 150 * Call the init function if it exists, and collect errors.
6fc14d53
A
151 * Add them in $loadedPlugins if successful.
152 *
153 * @param string $dir plugin's directory.
154 * @param string $pluginName plugin's name.
155 *
156 * @return void
e1850388 157 * @throws \Shaarli\Plugin\Exception\PluginFileNotFoundException - plugin files not found.
6fc14d53
A
158 */
159 private function loadPlugin($dir, $pluginName)
160 {
161 if (!is_dir($dir)) {
162 throw new PluginFileNotFoundException($pluginName);
163 }
164
165 $pluginFilePath = $dir . '/' . $pluginName . '.php';
166 if (!is_file($pluginFilePath)) {
167 throw new PluginFileNotFoundException($pluginName);
168 }
169
51def0d8 170 $conf = $this->conf;
6fc14d53
A
171 include_once $pluginFilePath;
172
7fde6de1
A
173 $initFunction = $pluginName . '_init';
174 if (function_exists($initFunction)) {
175 $errors = call_user_func($initFunction, $this->conf);
176 if (!empty($errors)) {
177 $this->errors = array_merge($this->errors, $errors);
178 }
179 }
180
a6e9c084
A
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
6fc14d53
A
197 $this->loadedPlugins[] = $pluginName;
198 }
199
200 /**
201 * Construct normalize hook name for a specific plugin.
202 *
203 * Format:
204 * hook_<plugin_name>_<hook_name>
205 *
206 * @param string $hook hook name.
207 * @param string $pluginName plugin name.
208 *
209 * @return string - plugin's hook name.
210 */
211 public function buildHookName($hook, $pluginName)
212 {
213 return 'hook_' . $pluginName . '_' . $hook;
214 }
dea0ba28
A
215
216 /**
217 * Retrieve plugins metadata from *.meta (INI) files into an array.
218 * Metadata contains:
219 * - plugin description [description]
220 * - parameters split with ';' [parameters]
221 *
222 * Respects plugins order from settings.
223 *
224 * @return array plugins metadata.
225 */
226 public function getPluginsMeta()
227 {
53054b2b 228 $metaData = [];
dea0ba28
A
229 $dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR | GLOB_MARK);
230
231 // Browse all plugin directories.
232 foreach ($dirs as $pluginDir) {
233 $plugin = basename($pluginDir);
234 $metaFile = $pluginDir . $plugin . '.' . self::$META_EXT;
235 if (!is_file($metaFile) || !is_readable($metaFile)) {
236 continue;
237 }
238
239 $metaData[$plugin] = parse_ini_file($metaFile);
240 $metaData[$plugin]['order'] = array_search($plugin, $this->authorizedPlugins);
241
12266213
A
242 if (isset($metaData[$plugin]['description'])) {
243 $metaData[$plugin]['description'] = t($metaData[$plugin]['description']);
244 }
dea0ba28
A
245 // Read parameters and format them into an array.
246 if (isset($metaData[$plugin]['parameters'])) {
247 $params = explode(';', $metaData[$plugin]['parameters']);
248 } else {
53054b2b 249 $params = [];
dea0ba28 250 }
53054b2b 251 $metaData[$plugin]['parameters'] = [];
dea0ba28
A
252 foreach ($params as $param) {
253 if (empty($param)) {
254 continue;
255 }
256
15170b51
A
257 $metaData[$plugin]['parameters'][$param]['value'] = '';
258 // Optional parameter description in parameter.PARAM_NAME=
e1850388
V
259 if (isset($metaData[$plugin]['parameter.' . $param])) {
260 $metaData[$plugin]['parameters'][$param]['desc'] = t($metaData[$plugin]['parameter.' . $param]);
15170b51 261 }
dea0ba28
A
262 }
263 }
264
265 return $metaData;
266 }
7fde6de1 267
a6e9c084
A
268 /**
269 * @return array List of registered custom routes by plugins.
270 */
271 public function getRegisteredRoutes(): array
272 {
273 return $this->registeredRoutes;
274 }
275
7fde6de1
A
276 /**
277 * Return the list of encountered errors.
278 *
279 * @return array List of errors (empty array if none exists).
280 */
281 public function getErrors()
282 {
283 return $this->errors;
284 }
a6e9c084
A
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 }
6fc14d53 313}