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