Passed
Push — develop ( 065b64...9a9917 )
by Nikolay
08:43 queued 03:21
created

PbxExtensionState   F

Complexity

Total Complexity 85

Size/Duplication

Total Lines 494
Duplicated Lines 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
wmc 85
eloc 245
c 3
b 0
f 0
dl 0
loc 494
rs 2

10 Methods

Rating   Name   Duplication   Size   Complexity  
A getMessages() 0 3 1
C disableModule() 0 49 12
A cleanupVoltCache() 0 8 3
B enableModule() 0 41 10
B disableFirewallSettings() 0 51 9
B enableFirewallSettings() 0 60 11
D makeBeforeEnableTest() 0 94 18
A reloadConfigClass() 0 8 2
A __construct() 0 32 4
C makeBeforeDisableTest() 0 69 15

How to fix   Complexity   

Complex Class

Complex classes like PbxExtensionState often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use PbxExtensionState, and based on these observations, apply Extract Interface, too.

1
<?php
2
/*
3
 * MikoPBX - free phone system for small business
4
 * Copyright © 2017-2023 Alexey Portnov and Nikolay Beketov
5
 *
6
 * This program is free software: you can redistribute it and/or modify
7
 * it under the terms of the GNU General Public License as published by
8
 * the Free Software Foundation; either version 3 of the License, or
9
 * (at your option) any later version.
10
 *
11
 * This program is distributed in the hope that it will be useful,
12
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
 * GNU General Public License for more details.
15
 *
16
 * You should have received a copy of the GNU General Public License along with this program.
17
 * If not, see <https://www.gnu.org/licenses/>.
18
 */
19
20
namespace MikoPBX\Modules;
21
22
use MikoPBX\Common\Models\FirewallRules;
23
use MikoPBX\Common\Models\NetworkFilters;
24
use MikoPBX\Common\Models\PbxExtensionModules;
25
use MikoPBX\Common\Models\PbxSettings;
26
use MikoPBX\Common\Providers\ConfigProvider;
27
use MikoPBX\Core\System\Processes;
28
use MikoPBX\Core\System\Util;
29
use MikoPBX\Modules\Config\ConfigClass;
30
use MikoPBX\Modules\Config\SystemConfigInterface;
31
use Phalcon\Di\Injectable;
32
use ReflectionClass;
33
use Throwable;
34
35
/**
36
 *  Utility class for managing extension state.
37
 *
38
 * @property \MikoPBX\Service\License license
39
 *
40
 * @package MikoPBX\Modules
41
 */
42
class PbxExtensionState extends Injectable
43
{
44
    private array $messages;
45
    private $lic_feature_id;
46
    private string $moduleUniqueID;
47
    private ?ConfigClass $configClass;
48
    private $modulesRoot;
49
50
51
    /**
52
     * PbxExtensionState constructor.
53
     *
54
     * @param string $moduleUniqueID The unique ID of the module
55
     */
56
    public function __construct(string $moduleUniqueID)
57
    {
58
        $this->configClass    = null;
59
        $this->messages       = [];
60
        $this->moduleUniqueID = $moduleUniqueID;
61
        $this->modulesRoot    = $this->getDI()->getShared(ConfigProvider::SERVICE_NAME)->path('core.modulesDir');
62
63
        // Check if module.json file exists
64
        $moduleJson           = "{$this->modulesRoot}/{$this->moduleUniqueID}/module.json";
65
        if ( ! file_exists($moduleJson)) {
66
            $this->messages[] = 'module.json not found for module ' . $this->moduleUniqueID;
67
            return;
68
        }
69
70
        // Read and parse module.json
71
        $jsonString            = file_get_contents($moduleJson);
72
        $jsonModuleDescription = json_decode($jsonString, true);
73
        if ( ! is_array($jsonModuleDescription)) {
74
            $this->messages[] = 'module.json parsing error ' . $this->moduleUniqueID;
75
76
            return;
77
        }
78
79
        // Extract the lic_feature_id if present, otherwise set it to 0
80
        if (array_key_exists('lic_feature_id', $jsonModuleDescription)) {
81
            $this->lic_feature_id = $jsonModuleDescription['lic_feature_id'];
82
        } else {
83
            $this->lic_feature_id = 0;
84
        }
85
86
        // Reload the config class
87
        $this->reloadConfigClass();
88
    }
89
90
    /**
91
     * Reloads the configuration class for the module.
92
     * The configuration class is determined based on the module's unique ID.
93
     * If the configuration class exists, it is instantiated and assigned to the `configClass` property.
94
     * If the configuration class does not exist, the `configClass` property is set to `null`.
95
     */
96
    private function reloadConfigClass(): void
97
    {
98
        $class_name      = str_replace('Module', '', $this->moduleUniqueID);
99
        $configClassName = "\\Modules\\{$this->moduleUniqueID}\\Lib\\{$class_name}Conf";
100
        if (class_exists($configClassName)) {
101
            $this->configClass = new $configClassName();
102
        } else {
103
            $this->configClass = null;
104
        }
105
    }
106
107
    /**
108
     * Enables the extension module by checking relations.
109
     *
110
     * @return bool True if the module was successfully enabled, false otherwise.
111
     */
112
    public function enableModule(): bool
113
    {
114
        if ($this->lic_feature_id > 0) {
115
            // Try to capture the feature if it is set
116
            $result = $this->license->featureAvailable($this->lic_feature_id);
117
            if ($result['success'] === false) {
118
                $textError = (string)($result['error']??'');
119
                $this->messages[] = $this->license->translateLicenseErrorMessage($textError);
120
121
                return false;
122
            }
123
        }
124
        $success = $this->makeBeforeEnableTest();
125
        if ( ! $success) {
126
            return false;
127
        }
128
129
        // If there are no errors, enable the firewall and the module
130
        if ( ! $this->enableFirewallSettings()) {
131
            $this->messages[] = 'Error on enable firewall settings';
132
133
            return false;
134
        }
135
        if ($this->configClass !== null
136
            && method_exists($this->configClass, SystemConfigInterface::ON_BEFORE_MODULE_ENABLE)) {
137
            call_user_func([$this->configClass, SystemConfigInterface::ON_BEFORE_MODULE_ENABLE]);
138
        }
139
        $module = PbxExtensionModules::findFirstByUniqid($this->moduleUniqueID);
140
        if ($module !== null) {
141
            $module->disabled = '0';
142
            $module->save();
143
        }
144
        if ($this->configClass !== null
145
            && method_exists($this->configClass, 'getMessages')) {
146
            $this->messages = array_merge($this->messages, $this->configClass->getMessages());
147
        }
148
149
        // Cleanup volt cache, because them module can interact with volt templates
150
        $this->cleanupVoltCache();
151
152
        return true;
153
    }
154
155
    /**
156
     * Enables the firewall settings for the module by restoring previous settings or setting the default state.
157
     *
158
     * @return bool True if the firewall settings were successfully enabled, false otherwise.
159
     */
160
    protected function enableFirewallSettings(): bool
161
    {
162
        if ($this->configClass === null
163
            || method_exists($this->configClass, SystemConfigInterface::GET_DEFAULT_FIREWALL_RULES) === false
164
            || call_user_func([$this->configClass, SystemConfigInterface::GET_DEFAULT_FIREWALL_RULES]) === []
165
        ) {
166
            return true;
167
        }
168
169
        $this->db->begin(true);
170
        $defaultRules         = call_user_func([$this->configClass, SystemConfigInterface::GET_DEFAULT_FIREWALL_RULES]);
171
172
        // Retrieve previous rule settings
173
        $previousRuleSettings = PbxSettings::findFirstByKey("{$this->moduleUniqueID}FirewallSettings");
174
        $previousRules        = [];
175
        if ($previousRuleSettings !== null) {
176
            $previousRules = json_decode($previousRuleSettings->value, true);
177
            $previousRuleSettings->delete();
178
        }
179
        $errors   = [];
180
        $networks = NetworkFilters::find();
181
        $key      = strtoupper(key($defaultRules));
182
        $record   = $defaultRules[key($defaultRules)];
183
184
        $oldRules = FirewallRules::findByCategory($key);
185
        if ($oldRules->count() > 0) {
186
            $oldRules->delete();
187
        }
188
189
        foreach ($networks as $network) {
190
            foreach ($record['rules'] as $detailRule) {
191
                $newRule                  = new FirewallRules();
192
                $newRule->networkfilterid = $network->id;
193
                $newRule->protocol        = $detailRule['protocol'];
194
                $newRule->portfrom        = $detailRule['portfrom'];
195
                $newRule->portto          = $detailRule['portto'];
196
                $newRule->category        = $key;
197
                $newRule->action          = $record['action'];
198
                $newRule->portFromKey     = $detailRule['portFromKey'];
199
                $newRule->portToKey       = $detailRule['portToKey'];
200
                $newRule->description     = $detailRule['name'];
201
202
                if (array_key_exists($network->id, $previousRules)) {
203
                    $newRule->action = $previousRules[$network->id];
204
                }
205
                if ( ! $newRule->save()) {
206
                    $errors[] = $newRule->getMessages();
207
                }
208
            }
209
        }
210
        if (count($errors) > 0) {
211
            $this->messages[] = array_merge($this->messages, $errors);
212
            $this->db->rollback(true);
213
214
            return false;
215
        }
216
217
        $this->db->commit(true);
218
219
        return true;
220
    }
221
222
    /**
223
     * Disables the extension module and performs necessary checks.
224
     *
225
     * @return bool True if the module was successfully disabled, false otherwise.
226
     */
227
    public function disableModule(): bool
228
    {
229
        // Perform necessary checks before disabling the module
230
        $success = $this->makeBeforeDisableTest();
231
        if ( ! $success) {
232
            return false;
233
        }
234
235
        // Disable firewall settings and the module
236
        if ( ! $this->disableFirewallSettings()) {
237
            $this->messages[] = 'Error on disable firewall settings';
238
239
            return false;
240
        }
241
242
        // Call the onBeforeModuleDisable method if available in the configClass
243
        if ($this->configClass !== null
244
            && method_exists($this->configClass, SystemConfigInterface::ON_BEFORE_MODULE_DISABLE)) {
245
            call_user_func([$this->configClass, SystemConfigInterface::ON_BEFORE_MODULE_DISABLE]);
246
        }
247
248
        // Find and update the module's disabled flag in the database
249
        $module = PbxExtensionModules::findFirstByUniqid($this->moduleUniqueID);
250
        if ($module !== null) {
251
            $module->disabled = '1';
252
            $module->save();
253
        }
254
255
        // Merge any additional messages from the configClass
256
        if ($this->configClass !== null
257
            && method_exists($this->configClass, 'getMessages')) {
258
            $this->messages = array_merge($this->messages, $this->configClass->getMessages());
259
        }
260
261
        // Kill module workers if specified in the configClass
262
        if ($this->configClass !== null
263
            && method_exists($this->configClass, SystemConfigInterface::GET_MODULE_WORKERS)) {
264
            $workersToKill = call_user_func([$this->configClass, SystemConfigInterface::GET_MODULE_WORKERS]);
265
            if (is_array($workersToKill)) {
266
                foreach ($workersToKill as $moduleWorker) {
267
                    Processes::killByName($moduleWorker['worker']);
268
                }
269
            }
270
        }
271
272
        // Cleanup volt cache, because them module can interact with volt templates
273
        $this->cleanupVoltCache();
274
275
        return true;
276
    }
277
278
    /**
279
     * Performs necessary checks before disabling the module.
280
     *
281
     * @return bool True if the checks pass and the module can be disabled, false otherwise.
282
     */
283
    private function makeBeforeDisableTest(): bool
284
    {
285
        // Check if there are any configured dependencies in other modules
286
        // Attempt to remove all module settings
287
        // Start a temporary transaction for the checks
288
        $this->db->begin(true);
289
        $success = true;
290
291
        if ($this->configClass !== null
292
            && method_exists($this->configClass, SystemConfigInterface::ON_BEFORE_MODULE_DISABLE)
293
            && call_user_func([$this->configClass, SystemConfigInterface::ON_BEFORE_MODULE_DISABLE]) === false) {
294
            // Call the module's ON_BEFORE_MODULE_DISABLE method and check the result
295
            $messages = $this->configClass->getMessages();
296
            if ( ! empty($messages)) {
297
                $this->messages = $messages;
298
            } else {
299
                $this->messages[] = 'Error on the Module enable function at onBeforeModuleDisable';
300
            }
301
            $this->db->rollback(true); // Rollback the transaction
302
303
            return false;
304
        }
305
306
        // Attempt to remove the current module, if no errors occur, it can be disabled
307
        // For example, the module may be referenced by a record in the Extensions table,
308
        // which needs to be deleted when the module is disabled
309
        $modelsFiles = glob("{$this->modulesRoot}/{$this->moduleUniqueID}/Models/*.php", GLOB_NOSORT);
310
        foreach ($modelsFiles as $file) {
311
            $className        = pathinfo($file)['filename'];
312
            $moduleModelClass = "\\Modules\\{$this->moduleUniqueID}\\Models\\{$className}";
313
            try {
314
                if ( ! class_exists($moduleModelClass)) {
315
                    continue;
316
                }
317
                $reflection = new ReflectionClass($moduleModelClass);
318
                if ($reflection->isAbstract()) {
319
                    continue;
320
                }
321
                if (count($reflection->getProperties()) === 0) {
322
                    continue;
323
                }
324
                $records = $moduleModelClass::find();
325
                foreach ($records as $record) {
326
                    $relations = $record->_modelsManager->getRelations(get_class($record));
327
                    if(empty($relations)){
328
                        // If the model does not have relations, skip it
329
                        // Potential performance issue for large tables
330
                        break;
331
                    }
332
                    if ( ! $record->beforeDelete()) {
333
                        foreach ($record->getMessages() as $message) {
334
                            $this->messages[] = $message->getMessage();
335
                        }
336
                        $success = false;
337
                    }
338
                }
339
            } catch (Throwable $exception) {
340
                $this->messages[] = $exception->getMessage();
341
                $success          = false;
342
            }
343
        }
344
        if ($success) {
345
            $this->messages = [];
346
        }
347
348
        // Rollback the transaction
349
        $this->db->rollback(true);
350
351
        return $success;
352
    }
353
354
    /**
355
     * Disables the firewall settings for the module.
356
     *
357
     * @return bool True if the firewall settings are disabled successfully, false otherwise.
358
     */
359
    protected function disableFirewallSettings(): bool
360
    {
361
        if ($this->configClass === null
362
            || method_exists($this->configClass, SystemConfigInterface::GET_DEFAULT_FIREWALL_RULES) === false
363
            || call_user_func([$this->configClass, SystemConfigInterface::GET_DEFAULT_FIREWALL_RULES]) === []
364
        ) {
365
            return true;
366
        }
367
        $errors       = [];
368
        $savedState   = [];
369
        $defaultRules = call_user_func([$this->configClass, SystemConfigInterface::GET_DEFAULT_FIREWALL_RULES]);
370
371
        // Retrieve the category key and current rules for the firewall
372
        $key          = strtoupper(key($defaultRules));
373
        $currentRules = FirewallRules::findByCategory($key);
374
375
        // Store the current firewall settings for later restoration
376
        foreach ($currentRules as $detailRule) {
377
            $savedState[$detailRule->networkfilterid] = $detailRule->action;
378
        }
379
        $this->db->begin(true);
380
381
        // Delete the current firewall rules
382
        if ( ! $currentRules->delete()) {
383
            $this->messages[] = $currentRules->getMessages();
384
385
            return false;
386
        }
387
388
        // Save the previous firewall settings
389
        $previousRuleSettings = PbxSettings::findFirstByKey("{$this->moduleUniqueID}FirewallSettings");
390
        if ($previousRuleSettings === null) {
391
            $previousRuleSettings      = new PbxSettings();
392
            $previousRuleSettings->key = "{$this->moduleUniqueID}FirewallSettings";
393
        }
394
        $previousRuleSettings->value = json_encode($savedState);
395
        if ( ! $previousRuleSettings->save()) {
396
            $errors[] = $previousRuleSettings->getMessages();
397
        }
398
399
        // Rollback and return false if there are any errors
400
        if (count($errors) > 0) {
401
            $this->messages[] = array_merge($this->messages, $errors);
402
            $this->db->rollback(true);
403
404
            return false;
405
        }
406
407
        $this->db->commit(true);
408
409
        return true;
410
    }
411
412
    /**
413
     * Returns messages after function or methods execution
414
     *
415
     * @return array
416
     */
417
    public function getMessages(): array
418
    {
419
        return $this->messages;
420
    }
421
422
    /**
423
     * Performs the necessary checks before enabling the module.
424
     *
425
     * @return bool True if the checks passed successfully, false otherwise.
426
     */
427
    private function makeBeforeEnableTest(): bool
428
    {
429
        $success = true;
430
431
        // Start a temporary transaction for the checks
432
        $this->db->begin(true);
433
434
        // Temporarily enable the module to handle all links and dependencies
435
        $module = PbxExtensionModules::findFirstByUniqid($this->moduleUniqueID);
436
        if ($module !== null) {
437
            $module->disabled = '0';
438
            $module->save();
439
        }
440
441
        // If the module's configuration class contains a function for proper inclusion,
442
        // we will invoke it. For example, the intelligent routing module registers itself in the routes.
443
        //
444
        // Execute the "ON_BEFORE_MODULE_ENABLE" function in the module config, if available
445
        if ($this->configClass !== null
446
            && method_exists($this->configClass, SystemConfigInterface::ON_BEFORE_MODULE_ENABLE)
447
            && call_user_func([$this->configClass, SystemConfigInterface::ON_BEFORE_MODULE_ENABLE]) === false) {
448
            $messages = $this->configClass->getMessages();
449
            if ( ! empty($messages)) {
450
                $this->messages = $messages;
451
            } else {
452
                $this->messages[] = 'Error on the enableModule function at onBeforeModuleEnable';
453
            }
454
            $this->db->rollback(true); // Rollback the temporary transaction
455
456
            return false;
457
        }
458
459
        // Check for broken references that prevent enabling the module
460
        // For example, if an employee has been deleted and the module references their extension.
461
        //
462
        $modelsFiles = glob("{$this->modulesRoot}/{$this->moduleUniqueID}/Models/*.php", GLOB_NOSORT);
463
        $translator  = $this->di->getShared('translation');
464
        foreach ($modelsFiles as $file) {
465
            $className        = pathinfo($file)['filename'];
466
            $moduleModelClass = "\\Modules\\{$this->moduleUniqueID}\\Models\\{$className}";
467
468
            try {
469
                if ( ! class_exists($moduleModelClass)) {
470
                    continue;
471
                }
472
                $reflection = new ReflectionClass($moduleModelClass);
473
                if ($reflection->isAbstract()) {
474
                    continue;
475
                }
476
                if (count($reflection->getProperties()) === 0) {
477
                    continue;
478
                }
479
                $records = $moduleModelClass::find();
480
                foreach ($records as $record) {
481
                    $relations = $record->_modelsManager->getRelations(get_class($record));
482
                    if(empty($relations)){
483
                        // If no relations are defined in the model, skip processing.
484
                        // This can be a potential issue for large tables.
485
                        break;
486
                    }
487
                    foreach ($relations as $relation) {
488
                        $alias        = $relation->getOption('alias');
489
                        $checkedValue = $record->$alias;
490
                        $foreignKey   = $relation->getOption('foreignKey');
491
                        // If the module has a "NULL" restriction in the model description,
492
                        // but the corresponding parameter is not filled in the module settings,
493
                        // e.g., a backup number in the routing module
494
                        if ($checkedValue === false
495
                            && array_key_exists('allowNulls', $foreignKey)
496
                            && $foreignKey['allowNulls'] === false
497
                        ) {
498
                            $this->messages[] = $translator->_(
499
                                'mo_ModuleSettingsError',
500
                                [
501
                                    'modulename' => $record->getRepresent(true),
502
                                ]
503
                            );
504
                            $success          = false;
505
                        }
506
                    }
507
                }
508
            } catch (Throwable $exception) {
509
                $this->messages[] = $exception->getMessage();
510
                $success          = false;
511
            }
512
        }
513
        if ($success) {
514
            $this->messages = [];
515
        }
516
517
        // Rollback the temporary transaction
518
        $this->db->rollback(true);
519
520
        return $success;
521
    }
522
523
    /**
524
     * Deletes old cache files.
525
     *
526
     * @return void
527
     */
528
    private function cleanupVoltCache():void
529
    {
530
        $cacheDirs = [];
531
        $cacheDirs[] = $this->config->path('adminApplication.voltCacheDir');
0 ignored issues
show
Bug Best Practice introduced by
The property config does not exist on MikoPBX\Modules\PbxExtensionState. Since you implemented __get, consider adding a @property annotation.
Loading history...
Bug introduced by
The method path() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

531
        /** @scrutinizer ignore-call */ 
532
        $cacheDirs[] = $this->config->path('adminApplication.voltCacheDir');

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
532
        $rmPath = Util::which('rm');
533
        foreach ($cacheDirs as $cacheDir) {
534
            if (!empty($cacheDir)) {
535
                Processes::mwExec("{$rmPath} -rf {$cacheDir}/*");
536
            }
537
        }
538
    }
539
}