Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.46% covered (success)
88.46%
92 / 104
77.78% covered (warning)
77.78%
14 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
PluginManager
88.46% covered (success)
88.46%
92 / 104
77.78% covered (warning)
77.78%
14 / 18
51.54
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 registerPlugin
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
4.00
 loadPlugins
78.38% covered (warning)
78.38%
29 / 37
0.00% covered (danger)
0.00%
0 / 1
12.22
 triggerEvent
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 loadPluginConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPluginConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPlugins
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isCompatible
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 areDependenciesMet
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 getPluginDirectory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 registerPluginStylesheets
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 getAllPluginStylesheets
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getPluginStylesheets
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 registerPluginScripts
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 getAllPluginScripts
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getPluginScripts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getIncompatiblePlugins
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMissingDependencies
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
1<?php
2
3/**
4 * The main PluginManager class
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2024-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2024-07-10
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Plugin;
21
22use phpMyFAQ\Core\Exception;
23use phpMyFAQ\Environment;
24use phpMyFAQ\System;
25use phpMyFAQ\Translation;
26use Symfony\Component\DependencyInjection\ContainerBuilder;
27use Symfony\Component\EventDispatcher\EventDispatcher;
28
29/**
30 * Class PluginManager
31 */
32class PluginManager
33{
34    /** @var PluginInterface[] */
35    private array $plugins = [];
36
37    private readonly EventDispatcher $eventDispatcher;
38
39    /** @var PluginConfigurationInterface[] */
40    private array $config = [];
41
42    /** @var string[] */
43    private array $loadedPlugins = [];
44
45    /** @var array<string, array{plugin: PluginInterface, reason: string}> */
46    private array $incompatiblePlugins = [];
47
48    /** @var array<string, string[]> Plugin stylesheets: [pluginName => [CSS paths]] */
49    private array $pluginStylesheets = [];
50
51    /** @var array<string, string[]> Plugin scripts: [pluginName => [js paths]] */
52    private array $pluginScripts = [];
53
54    private readonly ContainerBuilder $containerBuilder;
55
56    public function __construct()
57    {
58        $this->eventDispatcher = new EventDispatcher();
59        $this->containerBuilder = new ContainerBuilder();
60    }
61
62    /**
63     * Registers a plugin
64     */
65    public function registerPlugin(string $pluginClass): void
66    {
67        // A plugin class that does not exist or does not implement the plugin
68        // contract must not take the whole application down.
69        if (!class_exists($pluginClass) || !is_subclass_of($pluginClass, PluginInterface::class)) {
70            return;
71        }
72
73        $plugin = new $pluginClass();
74        if (!$this->isCompatible($plugin)) {
75            $this->incompatiblePlugins[$plugin->getName()] = [
76                'plugin' => $plugin,
77                'reason' => sprintf(
78                    'Plugin version %s is not compatible with system version %s',
79                    $plugin->getVersion(),
80                    System::getPluginVersion(),
81                ),
82            ];
83            return;
84        }
85
86        $this->plugins[$plugin->getName()] = $plugin;
87        $this->containerBuilder->register($plugin->getName(), $pluginClass);
88    }
89
90    /**
91     * Loads and registers all plugins
92     * @throws Exception
93     */
94    public function loadPlugins(): void
95    {
96        $pluginDir = (string) PMF_ROOT_DIR . '/content/plugins/';
97        // The manifest cache is skipped in debug mode and on development
98        // versions, where plugin namespaces may change without a directory
99        // mtime bump.
100        $manifestFile = Environment::isDebugMode() || System::isDevelopmentVersion()
101            ? null
102            : (string) PMF_ROOT_DIR . '/cache/plugins/manifest.php';
103        $pluginDiscovery = new PluginDiscovery($pluginDir, $manifestFile);
104
105        foreach ($pluginDiscovery->getClassMap() as $pluginFile => $fullClassName) {
106            require_once $pluginFile;
107            $this->registerPlugin($fullClassName);
108        }
109
110        foreach ($this->plugins as $plugin) {
111            if (!$this->areDependenciesMet($plugin)) {
112                $missingDeps = $this->getMissingDependencies($plugin);
113                $this->incompatiblePlugins[$plugin->getName()] = [
114                    'plugin' => $plugin,
115                    'reason' => sprintf('Missing dependencies: %s', implode(', ', $missingDeps)),
116                ];
117                // Remove plugin from the plugins array since it's incompatible
118                unset($this->plugins[$plugin->getName()]);
119                continue;
120            }
121
122            $this->loadedPlugins[] = $plugin->getName();
123            $plugin->registerEvents($this->eventDispatcher);
124            $pluginConfig = $plugin->getConfig();
125            if ($pluginConfig instanceof PluginConfigurationInterface) {
126                $this->loadPluginConfig($plugin->getName(), $pluginConfig);
127            }
128
129            // Register plugin translations
130            $translationsPath = $plugin->getTranslationsPath();
131            if ($translationsPath !== null) {
132                $pluginDir = $this->getPluginDirectory($plugin->getName());
133                $absoluteTranslationsPath = $pluginDir . '/' . $translationsPath;
134
135                if (is_dir($absoluteTranslationsPath)) {
136                    Translation::getInstance()->registerPluginTranslations(
137                        $plugin->getName(),
138                        $absoluteTranslationsPath,
139                    );
140                }
141            }
142
143            // Register plugin stylesheets
144            $stylesheets = $plugin->getStylesheets();
145            if ($stylesheets !== []) {
146                $this->registerPluginStylesheets($plugin->getName(), $stylesheets);
147            }
148
149            // Register plugin scripts
150            $scripts = $plugin->getScripts();
151            if ($scripts !== []) {
152                $this->registerPluginScripts($plugin->getName(), $scripts);
153            }
154        }
155    }
156
157    /**
158     * Handles the triggered event
159     *
160     * @param mixed|null $data
161     */
162    public function triggerEvent(string $eventName, mixed $data = null): string
163    {
164        $pluginEvent = new PluginEvent($data);
165        $this->eventDispatcher->dispatch($pluginEvent, $eventName);
166
167        return $pluginEvent->getOutput();
168    }
169
170    /**
171     * Loads the configuration for a plugin
172     */
173    public function loadPluginConfig(string $pluginName, PluginConfigurationInterface $pluginConfiguration): void
174    {
175        $this->config[$pluginName] = $pluginConfiguration;
176    }
177
178    /**
179     * Returns the configuration for a plugin
180     */
181    public function getPluginConfig(string $pluginName): ?PluginConfigurationInterface
182    {
183        return $this->config[$pluginName] ?? null;
184    }
185
186    public function getPlugins(): array
187    {
188        return $this->plugins;
189    }
190
191    /**
192     * Checks if a plugin is compatible with the current version
193     */
194    private function isCompatible(PluginInterface $plugin): bool
195    {
196        return version_compare(version1: $plugin->getVersion(), version2: System::getPluginVersion(), operator: '>=');
197    }
198
199    /**
200     * Checks if a plugin's dependencies are met
201     */
202    private function areDependenciesMet(PluginInterface $plugin): bool
203    {
204        if ($plugin->getDependencies() === []) {
205            return true;
206        }
207
208        foreach ($plugin->getDependencies() as $dependency) {
209            if (in_array($dependency, $this->loadedPlugins, strict: true)) {
210                continue;
211            }
212
213            return false;
214        }
215
216        return true;
217    }
218
219    /**
220     * Gets the absolute directory path for a plugin
221     */
222    private function getPluginDirectory(string $pluginName): string
223    {
224        return (string) PMF_ROOT_DIR . '/content/plugins/' . $pluginName;
225    }
226
227    /**
228     * Registers stylesheets for a plugin
229     *
230     * @param string[] $stylesheets Relative paths to CSS files
231     */
232    private function registerPluginStylesheets(string $pluginName, array $stylesheets): void
233    {
234        $pluginDir = $this->getPluginDirectory($pluginName);
235        $validatedStylesheets = [];
236
237        foreach ($stylesheets as $stylesheet) {
238            // Security: Validate a path to prevent directory traversal
239            $absolutePath = realpath($pluginDir . '/' . $stylesheet);
240
241            if ($absolutePath && str_starts_with($absolutePath, $pluginDir) && file_exists($absolutePath)) {
242                // Store relative path from web root for use in templates
243                $webPath = 'content/plugins/' . $pluginName . '/' . $stylesheet;
244                $validatedStylesheets[] = $webPath;
245            }
246        }
247
248        if ($validatedStylesheets !== []) {
249            $this->pluginStylesheets[$pluginName] = $validatedStylesheets;
250        }
251    }
252
253    /**
254     * Returns all registered plugin stylesheets for template injection
255     *
256     * @return string[] Array of CSS paths ready for <link> tags
257     */
258    public function getAllPluginStylesheets(): array
259    {
260        $allStylesheets = [];
261
262        foreach ($this->pluginStylesheets as $pluginStylesheet) {
263            $allStylesheets = array_merge($allStylesheets, $pluginStylesheet);
264        }
265
266        return $allStylesheets;
267    }
268
269    /**
270     * Returns stylesheets for a specific plugin
271     *
272     * @return string[]
273     */
274    public function getPluginStylesheets(string $pluginName): array
275    {
276        return $this->pluginStylesheets[$pluginName] ?? [];
277    }
278
279    /**
280     * Registers scripts for a plugin
281     *
282     * @param string[] $scripts Relative paths to JavaScript files
283     */
284    private function registerPluginScripts(string $pluginName, array $scripts): void
285    {
286        $pluginDir = $this->getPluginDirectory($pluginName);
287        $validatedScripts = [];
288
289        foreach ($scripts as $script) {
290            // Security: Validate path to prevent directory traversal
291            $absolutePath = realpath($pluginDir . '/' . $script);
292
293            if ($absolutePath && str_starts_with($absolutePath, $pluginDir) && file_exists($absolutePath)) {
294                // Store relative path from web root for use in templates
295                $webPath = 'content/plugins/' . $pluginName . '/' . $script;
296                $validatedScripts[] = $webPath;
297            }
298        }
299
300        if ($validatedScripts !== []) {
301            $this->pluginScripts[$pluginName] = $validatedScripts;
302        }
303    }
304
305    /**
306     * Returns all registered plugin scripts for template injection
307     *
308     * @return string[] Array of JavaScript paths ready for <script> tags
309     */
310    public function getAllPluginScripts(): array
311    {
312        $allScripts = [];
313
314        foreach ($this->pluginScripts as $pluginScript) {
315            $allScripts = array_merge($allScripts, $pluginScript);
316        }
317
318        return $allScripts;
319    }
320
321    /**
322     * Returns scripts for a specific plugin
323     *
324     * @return string[]
325     */
326    public function getPluginScripts(string $pluginName): array
327    {
328        return $this->pluginScripts[$pluginName] ?? [];
329    }
330
331    /**
332     * Returns all incompatible plugins with their reasons
333     *
334     * @return array<string, array{plugin: PluginInterface, reason: string}>
335     */
336    public function getIncompatiblePlugins(): array
337    {
338        return $this->incompatiblePlugins;
339    }
340
341    /**
342     * Gets the missing dependencies for a plugin
343     *
344     * @return string[]
345     */
346    private function getMissingDependencies(PluginInterface $plugin): array
347    {
348        $missingDeps = [];
349        foreach ($plugin->getDependencies() as $dependency) {
350            if (in_array($dependency, $this->loadedPlugins, strict: true)) {
351                continue;
352            }
353
354            $missingDeps[] = $dependency;
355        }
356
357        return $missingDeps;
358    }
359}