1<?php 2/** 3 * Copyright (c) 2010-2018 Combodo SARL 4 * 5 * This file is part of iTop. 6 * 7 * iTop is free software; you can redistribute it and/or modify 8 * it under the terms of the GNU Affero General Public License as published by 9 * the Free Software Foundation, either version 3 of the License, or 10 * (at your option) any later version. 11 * 12 * iTop is distributed in the hope that it will be useful, 13 * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 * GNU Affero General Public License for more details. 16 * 17 * You should have received a copy of the GNU Affero General Public License 18 * along with iTop. If not, see <http://www.gnu.org/licenses/> 19 * 20 */ 21 22class MissingDependencyException extends Exception 23{ 24 public $aModulesInfo; 25} 26 27class ModuleDiscovery 28{ 29 static $m_aModuleArgs = array( 30 'label' => 'One line description shown during the interactive setup', 31 'dependencies' => 'array of module ids', 32 'mandatory' => 'boolean', 33 'visible' => 'boolean', 34 'datamodel' => 'array of data model files', 35 //'dictionary' => 'array of dictionary files', // No longer mandatory, now automated 36 'data.struct' => 'array of structural data files', 37 'data.sample' => 'array of sample data files', 38 'doc.manual_setup' => 'url', 39 'doc.more_information' => 'url', 40 ); 41 42 43 // Cache the results and the source directories 44 protected static $m_aSearchDirs = null; 45 protected static $m_aModules = array(); 46 protected static $m_aModuleVersionByName = array(); 47 48 // All the entries below are list of file paths relative to the module directory 49 protected static $m_aFilesList = array('datamodel', 'webservice', 'dictionary', 'data.struct', 'data.sample'); 50 51 52 // ModulePath is used by AddModule to get the path of the module being included (in ListModuleFiles) 53 protected static $m_sModulePath = null; 54 protected static function SetModulePath($sModulePath) 55 { 56 self::$m_sModulePath = $sModulePath; 57 } 58 59 /** 60 * @param string $sFilePath 61 * @param string $sId 62 * @param array $aArgs 63 * 64 * @throws \Exception for missing parameter 65 */ 66 public static function AddModule($sFilePath, $sId, $aArgs) 67 { 68 if (!array_key_exists('itop_version', $aArgs)) 69 { 70 // Assume 1.0.2 71 $aArgs['itop_version'] = '1.0.2'; 72 } 73 foreach (array_keys(self::$m_aModuleArgs) as $sArgName) 74 { 75 if (!array_key_exists($sArgName, $aArgs)) 76 { 77 throw new Exception("Module '$sId': missing argument '$sArgName'"); 78 } 79 } 80 81 $aArgs['root_dir'] = dirname($sFilePath); 82 $aArgs['module_file'] = $sFilePath; 83 84 list($sModuleName, $sModuleVersion) = static::GetModuleName($sId); 85 if ($sModuleVersion == '') 86 { 87 $sModuleVersion = '1.0.0'; 88 } 89 90 if (array_key_exists($sModuleName, self::$m_aModuleVersionByName)) 91 { 92 if (version_compare($sModuleVersion, self::$m_aModuleVersionByName[$sModuleName]['version'], '>')) 93 { 94 // Newer version, let's upgrade 95 $sIdToRemove = self::$m_aModuleVersionByName[$sModuleName]['id']; 96 unset(self::$m_aModules[$sIdToRemove]); 97 98 self::$m_aModuleVersionByName[$sModuleName]['version'] = $sModuleVersion; 99 self::$m_aModuleVersionByName[$sModuleName]['id'] = $sId; 100 } 101 else 102 { 103 // Older (or equal) version, let's ignore it 104 return; 105 } 106 } 107 else 108 { 109 // First version to be loaded for this module, remember it 110 self::$m_aModuleVersionByName[$sModuleName]['version'] = $sModuleVersion; 111 self::$m_aModuleVersionByName[$sModuleName]['id'] = $sId; 112 } 113 114 self::$m_aModules[$sId] = $aArgs; 115 116 // Now keep the relative paths, as provided 117 /* 118 foreach(self::$m_aFilesList as $sAttribute) 119 { 120 if (isset(self::$m_aModules[$sId][$sAttribute])) 121 { 122 // All the items below are list of files, that are relative to the current file 123 // being loaded, let's update their path to store path relative to the application directory 124 foreach(self::$m_aModules[$sId][$sAttribute] as $idx => $sRelativePath) 125 { 126 self::$m_aModules[$sId][$sAttribute][$idx] = self::$m_sModulePath.'/'.$sRelativePath; 127 } 128 } 129 } 130 */ 131 // Populate automatically the list of dictionary files 132 $aMatches = array(); 133 if(preg_match('|^([^/]+)|', $sId, $aMatches)) // ModuleName = everything before the first forward slash 134 { 135 $sModuleName = $aMatches[1]; 136 $sDir = dirname($sFilePath); 137 if ($hDir = opendir($sDir)) 138 { 139 while (($sFile = readdir($hDir)) !== false) 140 { 141 $aMatches = array(); 142 if (preg_match("/^[^\\.]+.dict.$sModuleName.php$/i", $sFile, $aMatches)) // Dictionary files named like <Lang>.dict.<ModuleName>.php are loaded automatically 143 { 144 self::$m_aModules[$sId]['dictionary'][] = self::$m_sModulePath.'/'.$sFile; 145 } 146 } 147 closedir($hDir); 148 } 149 } 150 } 151 152 /** 153 * Get the list of "discovered" modules, ordered based on their (inter) dependencies 154 * 155 * @param bool $bAbortOnMissingDependency ... 156 * @param array $aModulesToLoad List of modules to search for, defaults to all if omitted 157 * 158 * @return array 159 * @throws \MissingDependencyException 160 */ 161 protected static function GetModules($bAbortOnMissingDependency = false, $aModulesToLoad = null) 162 { 163 // Order the modules to take into account their inter-dependencies 164 return self::OrderModulesByDependencies(self::$m_aModules, $bAbortOnMissingDependency, $aModulesToLoad); 165 } 166 167 /** 168 * Arrange an list of modules, based on their (inter) dependencies 169 * @param array $aModules The list of modules to process: 'id' => $aModuleInfo 170 * @param bool $bAbortOnMissingDependency ... 171 * @param array $aModulesToLoad List of modules to search for, defaults to all if omitted 172 * @return array 173 * @throws \MissingDependencyException 174*/ 175 public static function OrderModulesByDependencies($aModules, $bAbortOnMissingDependency = false, $aModulesToLoad = null) 176 { 177 // Order the modules to take into account their inter-dependencies 178 $aDependencies = array(); 179 $aSelectedModules = array(); 180 foreach($aModules as $sId => $aModule) 181 { 182 list($sModuleName, ) = self::GetModuleName($sId); 183 if (is_null($aModulesToLoad) || in_array($sModuleName, $aModulesToLoad)) 184 { 185 $aDependencies[$sId] = $aModule['dependencies']; 186 $aSelectedModules[$sModuleName] = true; 187 } 188 } 189 ksort($aDependencies); 190 $aOrderedModules = array(); 191 $iLoopCount = 1; 192 while(($iLoopCount < count($aModules)) && (count($aDependencies) > 0) ) 193 { 194 foreach($aDependencies as $sId => $aRemainingDeps) 195 { 196 $bDependenciesSolved = true; 197 foreach($aRemainingDeps as $sDepId) 198 { 199 if (!self::DependencyIsResolved($sDepId, $aOrderedModules, $aSelectedModules)) 200 { 201 $bDependenciesSolved = false; 202 } 203 } 204 if ($bDependenciesSolved) 205 { 206 $aOrderedModules[] = $sId; 207 unset($aDependencies[$sId]); 208 } 209 } 210 $iLoopCount++; 211 } 212 if ($bAbortOnMissingDependency && count($aDependencies) > 0) 213 { 214 $aModulesInfo = array(); 215 $aModuleDeps = array(); 216 foreach($aDependencies as $sId => $aDeps) 217 { 218 $aModule = $aModules[$sId]; 219 $aModuleDeps[] = "{$aModule['label']} (id: $sId) depends on ".implode(' + ', $aDeps); 220 $aModulesInfo[$sId] = array('module' => $aModule, 'dependencies' => $aDeps); 221 } 222 $sMessage = "The following modules have unmet dependencies: ".implode(', ', $aModuleDeps); 223 $oException = new MissingDependencyException($sMessage); 224 $oException->aModulesInfo = $aModulesInfo; 225 throw $oException; 226 } 227 // Return the ordered list, so that the dependencies are met... 228 $aResult = array(); 229 foreach($aOrderedModules as $sId) 230 { 231 $aResult[$sId] = $aModules[$sId]; 232 } 233 return $aResult; 234 } 235 236 /** 237 * Remove the duplicate modules (i.e. modules with the same name but with a different version) from the supplied list of modules 238 * @param array $aModules 239 * @return array The ordered modules as a duplicate-free list of modules 240 */ 241 public static function RemoveDuplicateModules($aModules) 242 { 243 // No longer needed, kept only for compatibility 244 // The de-duplication is now done directly by the AddModule method 245 return $aModules; 246 } 247 248 protected static function DependencyIsResolved($sDepString, $aOrderedModules, $aSelectedModules) 249 { 250 $bResult = false; 251 $aModuleVersions = array(); 252 // Separate the module names from their version for an easier comparison later 253 foreach($aOrderedModules as $sModuleId) 254 { 255 $aMatches = array(); 256 if (preg_match('|^([^/]+)/(.*)$|', $sModuleId, $aMatches)) 257 { 258 $aModuleVersions[$aMatches[1]] = $aMatches[2]; 259 } 260 else 261 { 262 // No version number found, assume 1.0.0 263 $aModuleVersions[$sModuleId] = '1.0.0'; 264 } 265 } 266 if (preg_match_all('/([^\(\)&| ]+)/', $sDepString, $aMatches)) 267 { 268 $aReplacements = array(); 269 $aPotentialPrerequisites = array(); 270 foreach($aMatches as $aMatch) 271 { 272 foreach($aMatch as $sModuleId) 273 { 274 // $sModuleId in the dependency string is made of a <name>/<optional_operator><version> 275 // where the operator is < <= = > >= (by default >=) 276 $aModuleMatches = array(); 277 if(preg_match('|^([^/]+)/(<?>?=?)([^><=]+)$|', $sModuleId, $aModuleMatches)) 278 { 279 $sModuleName = $aModuleMatches[1]; 280 $aPotentialPrerequisites[$sModuleName] = true; 281 $sOperator = $aModuleMatches[2]; 282 if ($sOperator == '') 283 { 284 $sOperator = '>='; 285 } 286 $sExpectedVersion = $aModuleMatches[3]; 287 if (array_key_exists($sModuleName, $aModuleVersions)) 288 { 289 // module is present, check the version 290 $sCurrentVersion = $aModuleVersions[$sModuleName]; 291 if (version_compare($sCurrentVersion, $sExpectedVersion, $sOperator)) 292 { 293 $aReplacements[$sModuleId] = '(true)'; // Add parentheses to protect against invalid condition causing 294 // a function call that results in a runtime fatal error 295 } 296 else 297 { 298 $aReplacements[$sModuleId] = '(false)'; // Add parentheses to protect against invalid condition causing 299 // a function call that results in a runtime fatal error 300 } 301 } 302 else 303 { 304 // module is not present 305 $aReplacements[$sModuleId] = '(false)'; // Add parentheses to protect against invalid condition causing 306 // a function call that results in a runtime fatal error 307 } 308 } 309 } 310 } 311 $bMissingPrerequisite = false; 312 foreach (array_keys($aPotentialPrerequisites) as $sModuleName) 313 { 314 if (array_key_exists($sModuleName, $aSelectedModules)) 315 { 316 // This module is actually a prerequisite 317 if (!array_key_exists($sModuleName, $aModuleVersions)) 318 { 319 $bMissingPrerequisite = true; 320 } 321 } 322 } 323 if ($bMissingPrerequisite) 324 { 325 $bResult = false; 326 } 327 else 328 { 329 $sBooleanExpr = str_replace(array_keys($aReplacements), array_values($aReplacements), $sDepString); 330 $bOk = @eval('$bResult = '.$sBooleanExpr.'; return true;'); 331 if ($bOk == false) 332 { 333 SetupPage::log_warning("Eval of '$sBooleanExpr' returned false"); 334 echo "Failed to parse the boolean Expression = '$sBooleanExpr'<br/>"; 335 } 336 } 337 } 338 return $bResult; 339 } 340 341 /** 342 * Search (on the disk) for all defined iTop modules, load them and returns the list (as an array) 343 * of the possible iTop modules to install 344 * 345 * @param $aSearchDirs array of directories to search (absolute paths) 346 * @param bool $bAbortOnMissingDependency ... 347 * @param array $aModulesToLoad List of modules to search for, defaults to all if omitted 348 * 349 * @return array A big array moduleID => ModuleData 350 * @throws \Exception 351 */ 352 public static function GetAvailableModules($aSearchDirs, $bAbortOnMissingDependency = false, $aModulesToLoad = null) 353 { 354 if (self::$m_aSearchDirs != $aSearchDirs) 355 { 356 self::ResetCache(); 357 } 358 359 if (is_null(self::$m_aSearchDirs)) 360 { 361 self::$m_aSearchDirs = $aSearchDirs; 362 363 // Not in cache, let's scan the disk 364 foreach($aSearchDirs as $sSearchDir) 365 { 366 $sLookupDir = realpath($sSearchDir); 367 if ($sLookupDir == '') 368 { 369 throw new Exception("Invalid directory '$sSearchDir'"); 370 } 371 372 clearstatcache(); 373 self::ListModuleFiles(basename($sSearchDir), dirname($sSearchDir)); 374 } 375 return self::GetModules($bAbortOnMissingDependency, $aModulesToLoad); 376 } 377 else 378 { 379 // Reuse the previous results 380 return self::GetModules($bAbortOnMissingDependency, $aModulesToLoad); 381 } 382 } 383 384 public static function ResetCache() 385 { 386 self::$m_aSearchDirs = null; 387 self::$m_aModules = array(); 388 self::$m_aModuleVersionByName = array(); 389 } 390 391 /** 392 * Helper function to interpret the name of a module 393 * @param $sModuleId string Identifier of the module, in the form 'name/version' 394 * @return array(name, version) 395 */ 396 public static function GetModuleName($sModuleId) 397 { 398 $aMatches = array(); 399 if (preg_match('!^(.*)/(.*)$!', $sModuleId, $aMatches)) 400 { 401 $sName = $aMatches[1]; 402 $sVersion = $aMatches[2]; 403 } 404 else 405 { 406 $sName = $sModuleId; 407 $sVersion = ""; 408 } 409 return array($sName, $sVersion); 410 } 411 412 /** 413 * Helper function to browse a directory and get the modules 414 * 415 * @param $sRelDir string Directory to start from 416 * @param $sRootDir string The root directory path 417 * 418 * @throws \Exception 419 */ 420 protected static function ListModuleFiles($sRelDir, $sRootDir) 421 { 422 static $iDummyClassIndex = 0; 423 $sDirectory = $sRootDir.'/'.$sRelDir; 424 425 if ($hDir = opendir($sDirectory)) 426 { 427 // This is the correct way to loop over the directory. (according to the documentation) 428 while (($sFile = readdir($hDir)) !== false) 429 { 430 $aMatches = array(); 431 if (is_dir($sDirectory.'/'.$sFile)) 432 { 433 if (($sFile != '.') && ($sFile != '..') && ($sFile != '.svn')) 434 { 435 self::ListModuleFiles($sRelDir.'/'.$sFile, $sRootDir); 436 } 437 } 438 else if (preg_match('/^module\.(.*).php$/i', $sFile, $aMatches)) 439 { 440 self::SetModulePath($sRelDir); 441 try 442 { 443 $sModuleFileContents = file_get_contents($sDirectory.'/'.$sFile); 444 $sModuleFileContents = str_replace(array('<?php', '?>'), '', $sModuleFileContents); 445 $sModuleFileContents = str_replace('__FILE__', "'".addslashes($sDirectory.'/'.$sFile)."'", $sModuleFileContents); 446 preg_match_all('/class ([A-Za-z0-9_]+) extends ([A-Za-z0-9_]+)/', $sModuleFileContents, $aMatches); 447 //print_r($aMatches); 448 $idx = 0; 449 foreach($aMatches[1] as $sClassName) 450 { 451 if (class_exists($sClassName)) 452 { 453 // rename the class inside the code to prevent a "duplicate class" declaration 454 // and change its parent class as well so that nobody will find it and try to execute it 455 $sModuleFileContents = str_replace($sClassName.' extends '.$aMatches[2][$idx], $sClassName.'_'.($iDummyClassIndex++).' extends DummyHandler', $sModuleFileContents); 456 } 457 $idx++; 458 } 459 $bRet = eval($sModuleFileContents); 460 461 if ($bRet === false) 462 { 463 SetupPage::log_warning("Eval of $sRelDir/$sFile returned false"); 464 } 465 466 //echo "<p>Done.</p>\n"; 467 } 468 catch(ParseError $e) 469 { 470 // PHP 7 471 SetupPage::log_warning("Eval of $sRelDir/$sFile caused an exception: ".$e->getMessage()." at line ".$e->getLine()); 472 } 473 catch(Exception $e) 474 { 475 // Continue... 476 SetupPage::log_warning("Eval of $sRelDir/$sFile caused an exception: ".$e->getMessage()); 477 } 478 } 479 } 480 closedir($hDir); 481 } 482 else 483 { 484 throw new Exception("Data directory (".$sDirectory.") not found or not readable."); 485 } 486 } 487} // End of class 488 489 490/** Alias for backward compatibility with old module files in which 491 * the declaration of a module invokes SetupWebPage::AddModule() 492 * whereas the new form is ModuleDiscovery::AddModule() 493 */ 494class SetupWebPage extends ModuleDiscovery 495{ 496 // For backward compatibility with old modules... 497 public static function log_error($sText) 498 { 499 SetupPage::log_error($sText); 500 } 501 502 public static function log_warning($sText) 503 { 504 SetupPage::log_warning($sText); 505 } 506 507 public static function log_info($sText) 508 { 509 SetupPage::log_info($sText); 510 } 511 512 public static function log_ok($sText) 513 { 514 SetupPage::log_ok($sText); 515 } 516 517 public static function log($sText) 518 { 519 SetupPage::log($sText); 520 } 521} 522 523/** Ugly patch !!! 524 * In order to be able to analyse / load several times 525 * the same module file, we rename the class (to avoid duplicate class definitions) 526 * and we make the class extends the dummy class below in order to "deactivate" completely 527 * the class (in case some piece of code enumerate the classes derived from a well known class) 528 * Note that this will not work if someone enumerates the classes that implement a given interface 529 */ 530class DummyHandler { 531} 532 533