Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 184
0.00% covered (danger)
0.00%
0 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
ModuleManager
0.00% covered (danger)
0.00%
0 / 184
0.00% covered (danger)
0.00%
0 / 24
6642
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getLanguageFiles
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getUriLoad
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 getActiveModules
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
72
 isInstalled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isActive
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isRunning
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getAllModules
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 getInstalledModules
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 loadInfo
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 deactivate
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 deactivateModule
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 activate
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 activateModule
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 reInit
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 install
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
72
 uninstall
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
90
 installModule
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 installProviding
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 get
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 initModuleController
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getModuleInstance
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 initRequestModules
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getRoutedModules
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * Jingga
4 *
5 * PHP Version 8.1
6 *
7 * @package   phpOMS\Module
8 * @copyright Dennis Eichhorn
9 * @license   OMS License 2.0
10 * @version   1.0.0
11 * @link      https://jingga.app
12 */
13declare(strict_types=1);
14
15namespace phpOMS\Module;
16
17use phpOMS\Application\ApplicationAbstract;
18use phpOMS\Application\ApplicationInfo;
19use phpOMS\Autoloader;
20use phpOMS\DataStorage\Database\Query\Builder;
21use phpOMS\Message\RequestAbstract;
22use phpOMS\Module\Exception\InvalidModuleException;
23
24/**
25 * Module manager class.
26 *
27 * General module functionality such as listings and initialization.
28 *
29 * @package phpOMS\Module
30 * @license OMS License 2.0
31 * @link    https://jingga.app
32 * @since   1.0.0
33 */
34final class ModuleManager
35{
36    /**
37     * All modules that are running on this uri.
38     *
39     * @var \phpOMS\Module\ModuleAbstract[]
40     * @since 1.0.0
41     */
42    private array $running = [];
43
44    /**
45     * Application instance.
46     *
47     * @var ApplicationAbstract
48     * @since 1.0.0
49     */
50    private ApplicationAbstract $app;
51
52    /**
53     * Installed modules.
54     *
55     * @var array<string, ModuleInfo>
56     * @since 1.0.0
57     */
58    private array $installed = [];
59
60    /**
61     * All active modules (on all pages not just the ones that are running now).
62     *
63     * @var array<string, array>
64     * @since 1.0.0
65     */
66    private array $active = [];
67
68    /**
69     * Module path.
70     *
71     * @var string
72     * @since 1.0.0
73     */
74    private string $modulePath;
75
76    /**
77     * All modules in the module directory.
78     *
79     * @var array<string, ModuleInfo>
80     * @since 1.0.0
81     */
82    private array $all = [];
83
84    /**
85     * To load based on request uri.
86     *
87     * @var array<string, array>
88     * @since 1.0.0
89     */
90    private array $uriLoad = [];
91
92    /**
93     * Constructor.
94     *
95     * @param ApplicationAbstract $app        Application
96     * @param string              $modulePath Path to modules (must end with '/')
97     *
98     * @since 1.0.0
99     */
100    public function __construct(ApplicationAbstract $app, string $modulePath = '')
101    {
102        $this->app        = $app;
103        $this->modulePath = $modulePath;
104    }
105
106    /**
107     * Get language files.
108     *
109     * @param RequestAbstract $request Request
110     * @param null|string     $app     App name
111     *
112     * @return string[]
113     *
114     * @since 1.0.0
115     */
116    public function getLanguageFiles(RequestAbstract $request, string $app = null) : array
117    {
118        $files = $this->getUriLoad($request);
119        if (!isset($files['5'])) {
120            return [];
121        }
122
123        $lang = [];
124        foreach ($files['5'] as $module) {
125            $lang[] = '/Modules/'
126                . $module['module_load_from']
127                . '/Theme/'
128                . ($app ?? $this->app->appName)
129                . '/Lang/'
130                . $module['module_load_file'];
131        }
132
133        return $lang;
134    }
135
136    /**
137     * Get modules that run on this page.
138     *
139     * @param RequestAbstract $request Request
140     *
141     * @return array<int|string, array>
142     *
143     * @since 1.0.0
144     */
145    public function getUriLoad(RequestAbstract $request) : array
146    {
147        if (empty($this->uriLoad)) {
148            $uriHash = $request->getHash();
149
150            $query = new Builder($this->app->dbPool->get('select'));
151            $sth   = $query->select('module_load.module_load_type', 'module_load.*')
152                ->from('module_load')
153                ->innerJoin('module')->on('module_load.module_load_from', '=', 'module.module_id')->orOn('module_load.module_load_for', '=', 'module.module_id')
154                ->whereIn('module_load.module_load_pid', $uriHash)
155                ->andWhere('module.module_status', '=', ModuleStatus::ACTIVE)
156                ->execute();
157
158            if ($sth === null) {
159                return [];
160            }
161
162            $this->uriLoad = $sth->fetchAll(\PDO::FETCH_GROUP);
163        }
164
165        return $this->uriLoad;
166    }
167
168    /**
169     * Get all installed modules that are active (not just on this uri).
170     *
171     * @param bool $useCache Use Cache or load new
172     *
173     * @return array<string, array>
174     *
175     * @since 1.0.0
176     */
177    public function getActiveModules(bool $useCache = true) : array
178    {
179        if (empty($this->active) || !$useCache) {
180            $query = new Builder($this->app->dbPool->get('select'));
181            $sth   = $query->select('module.module_path')
182                ->from('module')
183                ->where('module.module_status', '=', ModuleStatus::ACTIVE)
184                ->execute();
185
186            if ($sth === null) {
187                return [];
188            }
189
190            $active = $sth->fetchAll(\PDO::FETCH_COLUMN);
191
192            foreach ($active as $module) {
193                $path = $this->modulePath . $module . '/info.json';
194
195                if (!\is_file($path)) {
196                    continue;
197                }
198
199                $content = \file_get_contents($path);
200
201                $json = \json_decode($content === false ? '[]' : $content, true);
202                if (!\is_array($json)) {
203                    return $this->active;
204                }
205
206                /** @var array{name:array{internal:string}} $json */
207                $this->active[$json['name']['internal']] = $json;
208            }
209        }
210
211        return $this->active;
212    }
213
214    /**
215     * Is module installed
216     *
217     * @param string $module Module name
218     *
219     * @return bool
220     *
221     * @since 1.0.0
222     */
223    public function isInstalled(string $module) : bool
224    {
225        return isset($this->getInstalledModules(false)[$module]);
226    }
227
228    /**
229     * Is module active
230     *
231     * @param string $module Module name
232     *
233     * @return bool
234     *
235     * @since 1.0.0
236     */
237    public function isActive(string $module) : bool
238    {
239        return isset($this->getActiveModules(false)[$module]);
240    }
241
242    /**
243     * Is module active
244     *
245     * @param string      $module  Module name
246     * @param null|string $ctlName Controller name
247     *
248     * @return bool
249     *
250     * @since 1.0.0
251     */
252    public function isRunning(string $module, string $ctlName = null) : bool
253    {
254        $name = '\\Modules\\' . $module . '\\Controller\\' . ($ctlName ?? $this->app->appName) . 'Controller';
255
256        return isset($this->running[$name]);
257    }
258
259    /**
260     * Get all modules in the module directory.
261     *
262     * @return array<string, ModuleInfo>
263     *
264     * @since 1.0.0
265     */
266    public function getAllModules() : array
267    {
268        if (empty($this->all)) {
269            \chdir($this->modulePath);
270            $files = \glob('*', \GLOB_ONLYDIR);
271
272            if ($files === false) {
273                return $this->all; // @codeCoverageIgnore
274            }
275
276            $c = \count($files);
277            for ($i = 0; $i < $c; ++$i) {
278                $info = $this->loadInfo($files[$i]);
279
280                if ($info !== null) {
281                    $this->all[$files[$i]] = $info;
282                }
283            }
284        }
285
286        return $this->all;
287    }
288
289    /**
290     * Get modules that are available from official resources.
291     *
292     * @return array
293     *
294     * @since 1.0.0
295     */
296    /*
297    public function getAvailableModules() : array
298    {
299        return [];
300    }
301    */
302
303    /**
304     * Get all installed modules.
305     *
306     * @param bool $useCache Use Cache
307     *
308     * @return array<string, ModuleInfo>
309     *
310     * @since 1.0.0
311     */
312    public function getInstalledModules(bool $useCache = true) : array
313    {
314        if (empty($this->installed) || !$useCache) {
315            $query = new Builder($this->app->dbPool->get());
316            $sth   = $query->select('module.module_path')
317                ->from('module')
318                ->where('module_status', '!=', ModuleStatus::AVAILABLE)
319                ->execute();
320
321            if ($sth === null) {
322                return $this->installed;
323            }
324
325            /** @var string[] $installed */
326            $installed = $sth->fetchAll(\PDO::FETCH_COLUMN);
327
328            foreach ($installed as $module) {
329                $info = $this->loadInfo($module);
330
331                if ($info !== null) {
332                    $this->installed[$module] = $info;
333                }
334            }
335        }
336
337        return $this->installed;
338    }
339
340    /**
341     * Load info of module.
342     *
343     * @param string $module Module name
344     *
345     * @return null|ModuleInfo
346     *
347     * @since 1.0.0
348     */
349    public function loadInfo(string $module) : ?ModuleInfo
350    {
351        $path = \realpath($this->modulePath . $module . '/info.json');
352        if ($path === false) {
353            return null;
354        }
355
356        $info = new ModuleInfo($path);
357        $info->load();
358
359        return $info;
360    }
361
362    /**
363     * Deactivate module.
364     *
365     * @param string $module Module name
366     *
367     * @return bool
368     *
369     * @since 1.0.0
370     */
371    public function deactivate(string $module) : bool
372    {
373        $installed = $this->getInstalledModules(false);
374        if (!isset($installed[$module])) {
375            return false;
376        }
377
378        try {
379            $info = $this->loadInfo($module);
380            if ($info === null) {
381                return false; // @codeCoverageIgnore
382            }
383
384            $this->deactivateModule($info);
385
386            return true;
387        } catch (\Exception $_) {
388            return false; // @codeCoverageIgnore
389        }
390    }
391
392    /**
393     * Deactivate module.
394     *
395     * @param ModuleInfo $info Module info
396     *
397     * @return void
398     *
399     * @throws InvalidModuleException Throws this exception in case the deactiviation doesn't exist
400     *
401     * @since 1.0.0
402     */
403    private function deactivateModule(ModuleInfo $info) : void
404    {
405        $class = '\\Modules\\' . $info->getDirectory() . '\\Admin\\Status';
406        if (!Autoloader::exists($class)) {
407            throw new InvalidModuleException($info->getDirectory());
408        }
409
410        /** @var $class DeactivateAbstract */
411        $class::deactivate($this->app, $info);
412    }
413
414    /**
415     * Deactivate module.
416     *
417     * @param string $module Module name
418     *
419     * @return bool
420     *
421     * @since 1.0.0
422     */
423    public function activate(string $module) : bool
424    {
425        $installed = $this->getInstalledModules(false);
426        if (!isset($installed[$module])) {
427            return false;
428        }
429
430        try {
431            $info = $this->loadInfo($module);
432            if ($info === null) {
433                return false; // @codeCoverageIgnore
434            }
435
436            $this->activateModule($info);
437
438            return true;
439        } catch (\Exception $_) {
440            return false; // @codeCoverageIgnore
441        }
442    }
443
444    /**
445     * Activate module.
446     *
447     * @param ModuleInfo $info Module info
448     *
449     * @return void
450     *
451     * @throws InvalidModuleException Throws this exception in case the activation doesn't exist
452     *
453     * @since 1.0.0
454     */
455    private function activateModule(ModuleInfo $info) : void
456    {
457        $class = '\\Modules\\' . $info->getDirectory() . '\\Admin\\Status';
458        if (!Autoloader::exists($class)) {
459            throw new InvalidModuleException($info->getDirectory());
460        }
461
462        /** @var $class ActivateAbstract */
463        $class::activate($this->app, $info);
464    }
465
466    /**
467     * Re-init module.
468     *
469     * @param string          $module  Module name
470     * @param ApplicationInfo $appInfo Application info
471     *
472     * @return void
473     *
474     * @throws InvalidModuleException Throws this exception in case the installer doesn't exist
475     *
476     * @since 1.0.0
477     */
478    public function reInit(string $module, ApplicationInfo $appInfo = null) : void
479    {
480        $info = $this->loadInfo($module);
481        if ($info === null) {
482            return;
483        }
484
485        $class = '\\Modules\\' . $info->getDirectory() . '\\Admin\\Installer';
486
487        if (!Autoloader::exists($class)) {
488            throw new InvalidModuleException($info->getDirectory());
489        }
490
491        /** @var $class InstallerAbstract */
492        $class::reInit($info, $appInfo);
493    }
494
495    /**
496     * Install module.
497     *
498     * @param string $module Module name
499     *
500     * @return bool
501     *
502     * @since 1.0.0
503     */
504    public function install(string $module) : bool
505    {
506        $installed = $this->getInstalledModules(false);
507        if (isset($installed[$module])) {
508            return true;
509        }
510
511        if (!\is_file($this->modulePath . $module . '/Admin/Installer.php')) {
512            return false;
513        }
514
515        try {
516            $info = $this->loadInfo($module);
517            if ($info === null) {
518                return false; // @codeCoverageIgnore
519            }
520
521            $this->installed[$module] = $info;
522            $this->installModule($info);
523
524            /* Install providing but only if receiving module is already installed */
525            $providing = $info->getProviding();
526            foreach ($providing as $key => $_) {
527                if (isset($installed[$key])) {
528                    $this->installProviding('/Modules/' . $module, $key);
529                }
530            }
531
532            /* Install receiving and applications */
533            foreach ($this->installed as $key => $_) {
534                $this->installProviding('/Modules/' . $key, $module);
535            }
536
537            return true;
538        } catch (\Throwable $_) {
539            return false; // @codeCoverageIgnore
540        }
541    }
542
543    /**
544     * Uninstall module.
545     *
546     * @param string $module Module name
547     *
548     * @return bool
549     *
550     * @throws InvalidModuleException
551     *
552     * @since 1.0.0
553     */
554    public function uninstall(string $module) : bool
555    {
556        $installed = $this->getInstalledModules(false);
557        if (!isset($installed[$module])) {
558            return false;
559        }
560
561        if (!\is_file($this->modulePath . $module . '/Admin/Uninstaller.php')) {
562            return false;
563        }
564
565        try {
566            $info = $this->loadInfo($module);
567            if ($info === null) {
568                return false; // @codeCoverageIgnore
569            }
570
571            $this->installed[$module] = $info;
572            // uninstall dependencies if not used by others
573            // uninstall providing for
574            // uninstall receiving from? no?
575            // uninstall module
576
577            $class = '\\Modules\\' . $info->getDirectory() . '\\Admin\\Uninstaller';
578            if (!Autoloader::exists($class)) {
579                throw new InvalidModuleException($info->getDirectory());
580            }
581
582            /** @var $class UninstallerAbstract */
583            $class::uninstall($this->app, $info);
584
585            if (isset($this->installed[$module])) {
586                unset($this->installed[$module]);
587            }
588
589            if (isset($this->running[$module])) {
590                unset($this->running[$module]);
591            }
592
593            if (isset($this->active[$module])) {
594                unset($this->active[$module]);
595            }
596
597            return true;
598        } catch (\Throwable $_) {
599            return false; // @codeCoverageIgnore
600        }
601    }
602
603    /**
604     * Install module itself.
605     *
606     * @param ModuleInfo $info Module info
607     *
608     * @return void
609     *
610     * @throws InvalidModuleException Throws this exception in case the installer doesn't exist
611     *
612     * @since 1.0.0
613     */
614    private function installModule(ModuleInfo $info) : void
615    {
616        $class = '\\Modules\\' . $info->getDirectory() . '\\Admin\\Installer';
617        if (!Autoloader::exists($class)) {
618            throw new InvalidModuleException($info->getDirectory());
619        }
620
621        /** @var InstallerAbstract $class */
622        $class::install($this->app, $info, $this->app->appSettings);
623    }
624
625    /**
626     * Install providing.
627     *
628     * Installing additional functionality for another module
629     *
630     * @param string $from From path
631     * @param string $for  For module
632     *
633     * @return void
634     *
635     * @since 1.0.0
636     */
637    public function installProviding(string $from, string $for) : void
638    {
639        if (!\is_file(__DIR__ . '/../..' . $from . '/Admin/Install/' . $for . '.php')) {
640            return;
641        }
642
643        $from = \strtr($from, '/', '\\');
644
645        $class = $from . '\\Admin\\Install\\' . $for;
646        $class::install($this->app, $this->modulePath);
647    }
648
649    /**
650     * Get module instance.
651     *
652     * This also returns inactive or uninstalled modules if they are still in the modules directory.
653     *
654     * @param string $module  Module name
655     * @param string $ctlName Controller name (null = current)
656     *
657     * @return object|\phpOMS\Module\ModuleAbstract
658     *
659     * @todo Remove docblock type hint hack "object".
660     *          The return type object is only used to stop the annoying warning that a method doesn't exist
661     *          if you chain call the methods part of the returned ModuleAbstract implementation.
662     *          Remove it once alternative inline type hinting is possible for the specific returned implementation.
663     *          This also causes phpstan type inspection errors, which we have to live with or ignore in the settings
664     *
665     * @since 1.0.0
666     */
667    public function get(string $module, string $ctlName = null) : ModuleAbstract
668    {
669        $name = '\\Modules\\' . $module . '\\Controller\\' . ($ctlName ?? $this->app->appName) . 'Controller';
670        if (!isset($this->running[$name])) {
671            $this->initModuleController($module, $ctlName);
672        }
673
674        /* @phpstan-ignore-next-line */
675        return $this->running[$name] ?? new NullModule();
676    }
677
678    /**
679     * Initialize module.
680     *
681     * Also registers controller in the dispatcher
682     *
683     * @param string $module  Module
684     * @param string $ctlName Controller name (null = current app)
685     *
686     * @return void
687     *
688     * @since 1.0.0
689     */
690    private function initModuleController(string $module, string $ctlName = null) : void
691    {
692        $name                 = '\\Modules\\' . $module . '\\Controller\\' . ($ctlName ?? $this->app->appName) . 'Controller';
693        $this->running[$name] = $this->getModuleInstance($module, $ctlName);
694
695        if ($this->app->dispatcher !== null) {
696            $this->app->dispatcher->set($this->running[$name], $name);
697        }
698    }
699
700    /**
701     * Gets and initializes modules.
702     *
703     * @param string $module  Module ID
704     * @param string $ctlName Controller name (null = current app)
705     *
706     * @return ModuleAbstract
707     *
708     * @since 1.0.0
709     */
710    public function getModuleInstance(string $module, string $ctlName = null) : ModuleAbstract
711    {
712        $class = '\\Modules\\' . $module . '\\Controller\\' . ($ctlName ?? $this->app->appName) . 'Controller';
713
714        if (!isset($this->running[$class])) {
715            if (Autoloader::exists($class)
716                || Autoloader::exists($class = '\\Modules\\' . $module . '\\Controller\\Controller')
717            ) {
718                try {
719                    /** @var ModuleAbstract $obj */
720                    $obj                   = new $class($this->app);
721                    $this->running[$class] = $obj;
722                } catch (\Throwable $_) {
723                    $this->running[$class] = new NullModule();
724                }
725            } else {
726                $this->running[$class] = new NullModule();
727            }
728        }
729
730        return $this->running[$class];
731    }
732
733    /**
734     * Initialize all modules for a request.
735     *
736     * @param RequestAbstract $request Request
737     * @param string          $ctlName Controller name (null = current app)
738     *
739     * @return void
740     *
741     * @since 1.0.0
742     */
743    public function initRequestModules(RequestAbstract $request, string $ctlName = null) : void
744    {
745        $toInit = $this->getRoutedModules($request);
746        foreach ($toInit as $module) {
747            $this->initModuleController($module, $ctlName);
748        }
749    }
750
751    /**
752     * Get modules that run on this page.
753     *
754     * @param RequestAbstract $request Request
755     *
756     * @return string[]
757     *
758     * @since 1.0.0
759     */
760    public function getRoutedModules(RequestAbstract $request) : array
761    {
762        $files   = $this->getUriLoad($request);
763        $modules = [];
764
765        if (isset($files['4'])) {
766            foreach ($files['4'] as $module) {
767                $modules[] = $module['module_load_file'];
768            }
769        }
770
771        return $modules;
772    }
773}