1<?php declare(strict_types = 1); 2/* 3** Zabbix 4** Copyright (C) 2001-2021 Zabbix SIA 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 2 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 17** along with this program; if not, write to the Free Software 18** Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 19**/ 20 21 22use Core\CModule, 23 CController as CAction; 24 25/** 26 * Module manager class for testing and loading user modules. 27 */ 28final class CModuleManager { 29 30 /** 31 * Highest supported manifest version. 32 */ 33 const MAX_MANIFEST_VERSION = 1; 34 35 /** 36 * Home path of modules. 37 * 38 * @var string 39 */ 40 private $modules_dir; 41 42 /** 43 * Manifest data of added modules. 44 * 45 * @var array 46 */ 47 private $manifests = []; 48 49 /** 50 * List of instantiated, initialized modules. 51 * 52 * @var array 53 */ 54 private $modules = []; 55 56 /** 57 * List of errors caused by module initialization. 58 * 59 * @var array 60 */ 61 private $errors = []; 62 63 /** 64 * @param string $modules_dir Home path of modules. 65 */ 66 public function __construct(string $modules_dir) { 67 $this->modules_dir = $modules_dir; 68 } 69 70 /** 71 * Get home path of modules. 72 * 73 * @return string 74 */ 75 public function getModulesDir(): string { 76 return $this->modules_dir; 77 } 78 79 /** 80 * Add module and prepare it's manifest data. 81 * 82 * @param string $relative_path Relative path to the module. 83 * @param string $id Stored module ID to optionally check the manifest module ID against. 84 * @param array|null $config Override configuration to use instead of one stored in the manifest file. 85 * 86 * @return array|null Either manifest data or null if manifest file had errors or IDs didn't match. 87 */ 88 public function addModule(string $relative_path, string $id = null, array $config = null): ?array { 89 $manifest = $this->loadManifest($relative_path); 90 91 // Ignore module without a valid manifest. 92 if ($manifest === null) { 93 return null; 94 } 95 96 // Ignore module with an unexpected id. 97 if ($id !== null && $manifest['id'] !== $id) { 98 return null; 99 } 100 101 // Use override configuration, if supplied. 102 if (is_array($config)) { 103 $manifest['config'] = $config; 104 } 105 106 $this->manifests[$relative_path] = $manifest; 107 108 return $manifest; 109 } 110 111 /** 112 * Get namespaces of all added modules. 113 * 114 * @return array 115 */ 116 public function getNamespaces(): array { 117 $namespaces = []; 118 119 foreach ($this->manifests as $relative_path => $manifest) { 120 $module_path = $this->modules_dir.'/'.$relative_path; 121 $namespaces['Modules\\'.$manifest['namespace']] = [$module_path]; 122 } 123 124 return $namespaces; 125 } 126 127 /** 128 * Check added modules for conflicts. 129 * 130 * @return array Lists of conflicts and conflicting modules. 131 */ 132 public function checkConflicts(): array { 133 $ids = []; 134 $namespaces = []; 135 $actions = []; 136 137 foreach ($this->manifests as $relative_path => $manifest) { 138 $ids[$manifest['id']][] = $relative_path; 139 $namespaces[$manifest['namespace']][] = $relative_path; 140 foreach (array_keys($manifest['actions']) as $action_name) { 141 $actions[$action_name][] = $relative_path; 142 } 143 } 144 145 foreach (['ids', 'namespaces', 'actions'] as $var) { 146 $$var = array_filter($$var, function($list) { 147 return count($list) > 1; 148 }); 149 } 150 151 $conflicts = []; 152 $conflicting_manifests = []; 153 154 foreach ($ids as $id => $relative_paths) { 155 $conflicts[] = _s('Identical ID (%1$s) is used by modules located at %2$s.', $id, 156 implode(', ', $relative_paths) 157 ); 158 $conflicting_manifests = array_merge($conflicting_manifests, $relative_paths); 159 } 160 161 foreach ($namespaces as $namespace => $relative_paths) { 162 $conflicts[] = _s('Identical namespace (%1$s) is used by modules located at %2$s.', $namespace, 163 implode(', ', $relative_paths) 164 ); 165 $conflicting_manifests = array_merge($conflicting_manifests, $relative_paths); 166 } 167 168 $relative_paths = array_unique(array_reduce($actions, function($carry, $item) { 169 return array_merge($carry, $item); 170 }, [])); 171 172 if ($relative_paths) { 173 $conflicts[] = _s('Identical actions are used by modules located at %1$s.', implode(', ', $relative_paths)); 174 $conflicting_manifests = array_merge($conflicting_manifests, $relative_paths); 175 } 176 177 return [ 178 'conflicts' => $conflicts, 179 'conflicting_manifests' => array_unique($conflicting_manifests) 180 ]; 181 } 182 183 /** 184 * Check, instantiate and initialize all added modules. 185 * 186 * @return array List of initialized modules. 187 */ 188 public function initModules(): array { 189 [ 190 'conflicts' => $this->errors, 191 'conflicting_manifests' => $conflicting_manifests 192 ] = $this->checkConflicts(); 193 194 $non_conflicting_manifests = array_diff_key($this->manifests, array_flip($conflicting_manifests)); 195 196 foreach ($non_conflicting_manifests as $relative_path => $manifest) { 197 $path = $this->modules_dir.'/'.$relative_path; 198 199 if (is_file($path.'/Module.php')) { 200 $module_class = implode('\\', ['Modules', $manifest['namespace'], 'Module']); 201 202 if (!class_exists($module_class, true)) { 203 $this->errors[] = _s('Wrong Module.php class name for module located at %1$s.', $relative_path); 204 205 continue; 206 } 207 } 208 else { 209 $module_class = CModule::class; 210 } 211 212 try { 213 /** @var CModule $instance */ 214 $instance = new $module_class($path, $manifest); 215 216 if ($instance instanceof CModule) { 217 $instance->init(); 218 219 $this->modules[$instance->getId()] = $instance; 220 } 221 else { 222 $this->errors[] = _s('Module.php class must extend %1$s for module located at %2$s.', 223 CModule::class, $relative_path 224 ); 225 } 226 } 227 catch (Exception $e) { 228 $this->errors[] = _s('%1$s - thrown by module located at %2$s.', $e->getMessage(), $relative_path); 229 } 230 } 231 232 return $this->modules; 233 } 234 235 /** 236 * Get add initialized modules. 237 * 238 * @return array 239 */ 240 public function getModules(): array { 241 return $this->modules; 242 } 243 244 /** 245 * Get loaded module instance associated with given action name. 246 * 247 * @param string $action_name 248 * 249 * @return CModule|null 250 */ 251 public function getModuleByActionName(string $action_name): ?CModule { 252 foreach ($this->modules as $module) { 253 if (array_key_exists($action_name, $module->getActions())) { 254 return $module; 255 } 256 } 257 258 return null; 259 } 260 261 /** 262 * Get actions of all initialized modules. 263 * 264 * @return array 265 */ 266 public function getActions(): array { 267 $actions = []; 268 269 foreach ($this->modules as $module) { 270 foreach ($module->getActions() as $name => $data) { 271 $actions[$name] = [ 272 'class' => implode('\\', ['Modules', $module->getNamespace(), 'Actions', 273 str_replace('/', '\\', $data['class']) 274 ]), 275 'layout' => array_key_exists('layout', $data) ? $data['layout'] : 'layout.htmlpage', 276 'view' => array_key_exists('view', $data) ? $data['view'] : null 277 ]; 278 } 279 } 280 281 return $actions; 282 } 283 284 /** 285 * Publish an event to all loaded modules. The module of the responsible action will be served last. 286 * 287 * @param CAction $action Action responsible for the current request. 288 * @param string $event Event to publish. 289 */ 290 public function publishEvent(CAction $action, string $event): void { 291 $action_module = $this->getModuleByActionName($action->getAction()); 292 293 foreach ($this->modules as $module) { 294 if ($module != $action_module) { 295 $module->$event($action); 296 } 297 } 298 299 if ($action_module) { 300 $action_module->$event($action); 301 } 302 } 303 304 /** 305 * Get errors encountered while module initialization. 306 * 307 * @return array 308 */ 309 public function getErrors(): array { 310 return $this->errors; 311 } 312 313 /** 314 * Load and parse module manifest file. 315 * 316 * @param string $relative_path Relative path to the module. 317 * 318 * @return array|null Either manifest data or null if manifest file had errors. 319 */ 320 private function loadManifest(string $relative_path): ?array { 321 $module_path = $this->modules_dir.'/'.$relative_path; 322 $manifest_file_name = $module_path.'/manifest.json'; 323 324 if (!is_file($manifest_file_name) || !is_readable($manifest_file_name)) { 325 return null; 326 } 327 328 $manifest = file_get_contents($manifest_file_name); 329 330 if ($manifest === false) { 331 return null; 332 } 333 334 $manifest = json_decode($manifest, true); 335 336 if (!is_array($manifest)) { 337 return null; 338 } 339 340 // Check required keys in manifest. 341 if (array_diff_key(array_flip(['manifest_version', 'id', 'name', 'namespace', 'version']), $manifest)) { 342 return null; 343 } 344 345 // Check manifest version. 346 if (!is_numeric($manifest['manifest_version']) || $manifest['manifest_version'] > self::MAX_MANIFEST_VERSION) { 347 return null; 348 } 349 350 // Check manifest namespace syntax. 351 if (!preg_match('/^[a-z_]+$/i', $manifest['namespace'])) { 352 return null; 353 } 354 355 // Ensure empty defaults. 356 $manifest += [ 357 'author' => '', 358 'url' => '', 359 'description' => '', 360 'actions' => [], 361 'config' => [] 362 ]; 363 364 return $manifest; 365 } 366} 367