Files
iTop/setup/extensionsmap.class.inc.php
odain ae620c6663 N°8760 - Audit uninstall of extensions that declare final classes - refactor extension computations and move them in ExtensionMap
N°8760 - Audit uninstall of extensions that declare final classes - refactor extension computations and move them in ExtensionMap
2026-01-20 15:53:27 +01:00

590 lines
20 KiB
PHP

<?php
use Combodo\iTop\Setup\ModuleDiscovery\ModuleFileReader;
use Combodo\iTop\Setup\ModuleDiscovery\ModuleFileReaderException;
require_once(APPROOT.'/setup/itopextension.class.inc.php');
require_once(APPROOT.'/setup/parameters.class.inc.php');
require_once(APPROOT.'/core/cmdbsource.class.inc.php');
require_once(APPROOT.'/setup/modulediscovery.class.inc.php');
require_once(APPROOT.'/setup/moduleinstaller.class.inc.php');
/**
* Helper class to discover all available extensions on a given iTop system
*/
class iTopExtensionsMap
{
/**
* The list of all discovered extensions
* @var array $aExtensions
*/
protected $aExtensions;
/**
* The list of all currently installed extensions
* @var array $aInstalledExtensions
*/
protected array $aInstalledExtensions;
protected array $aExtensionsByCode;
/**
* The list of directories browsed using the ReadDir method when building the map
* @var string[]
*/
protected $aScannedDirs;
/**
* The list of all discovered extensions
* @param string $sFromEnvironment The environment to scan
* @param bool $bNormailizeOldExtension true to "magically" convert some well-known old extensions (i.e. a set of modules) to the new iTopExtension format
* @return void
*/
public function __construct($sFromEnvironment = 'production', $aExtraDirs = [])
{
$this->aExtensions = [];
$this->aExtensionsByCode = [];
$this->aScannedDirs = [];
$this->ScanDisk($sFromEnvironment);
foreach ($aExtraDirs as $sDir) {
$this->ReadDir($sDir, iTopExtension::SOURCE_REMOTE);
}
$this->CheckDependencies();
}
/**
* Populate the list of available (pseudo)extensions by scanning the disk
* where the iTop files are located
* @param string $sEnvironment
* @return void
*/
protected function ScanDisk($sEnvironment)
{
if (!$this->ReadInstallationWizard(APPROOT.'/datamodels/2.x')) {
//no installation xml found in 2.x: let's read all extensions in 2.x first
if (!$this->ReadDir(APPROOT.'/datamodels/2.x', iTopExtension::SOURCE_WIZARD)) {
//nothing found in 2.x : fallback read in 1.x (flat structure)
$this->ReadDir(APPROOT.'/datamodels/1.x', iTopExtension::SOURCE_WIZARD);
}
}
$this->ReadDir(APPROOT.'/extensions', iTopExtension::SOURCE_MANUAL);
$this->ReadDir(APPROOT.'/data/'.$sEnvironment.'-modules', iTopExtension::SOURCE_REMOTE);
}
/**
* Read the information contained in the "installation.xml" file in the given directory
* and create pseudo extensions from the list of choices described in this file
* @param string $sDir
* @return boolean Return true if the installation.xml file exists and is readable
*/
protected function ReadInstallationWizard($sDir)
{
if (!is_readable($sDir.'/installation.xml')) {
return false;
}
$oXml = new XMLParameters($sDir.'/installation.xml');
foreach ($oXml->Get('steps') as $aStepInfo) {
if (array_key_exists('options', $aStepInfo)) {
$this->ProcessWizardChoices($aStepInfo['options']);
}
if (array_key_exists('alternatives', $aStepInfo)) {
$this->ProcessWizardChoices($aStepInfo['alternatives']);
}
}
return true;
}
/**
* Helper to process a "choice" array read from the installation.xml file
* @param array $aChoices
* @return void
*/
protected function ProcessWizardChoices($aChoices)
{
foreach ($aChoices as $aChoiceInfo) {
if (array_key_exists('extension_code', $aChoiceInfo)) {
$oExtension = new iTopExtension();
$oExtension->sCode = $aChoiceInfo['extension_code'];
$oExtension->sLabel = $aChoiceInfo['title'];
$oExtension->sDescription = $aChoiceInfo['description'];
if (array_key_exists('modules', $aChoiceInfo)) {
// Some wizard choices are not associated with any module
$oExtension->aModules = $aChoiceInfo['modules'];
}
if (array_key_exists('sub_options', $aChoiceInfo)) {
if (array_key_exists('options', $aChoiceInfo['sub_options'])) {
$this->ProcessWizardChoices($aChoiceInfo['sub_options']['options']);
}
if (array_key_exists('alternatives', $aChoiceInfo['sub_options'])) {
$this->ProcessWizardChoices($aChoiceInfo['sub_options']['alternatives']);
}
}
$this->AddExtension($oExtension);
}
}
}
/**
* Add an extension to the list of existing extensions, taking care of removing duplicates
* (only the latest/greatest version is kept)
* @param iTopExtension $oNewExtension
* @return void
*/
protected function AddExtension(iTopExtension $oNewExtension)
{
foreach ($this->aExtensions as $key => $oExtension) {
if ($oExtension->sCode == $oNewExtension->sCode) {
if (version_compare($oNewExtension->sVersion, $oExtension->sVersion, '>')) {
// This "new" extension is "newer" than the previous one, let's replace the previous one
unset($this->aExtensions[$key]);
$this->aExtensions[$oNewExtension->sCode.'/'.$oNewExtension->sVersion] = $oNewExtension;
$this->aExtensionsByCode[$oNewExtension->sCode] = $oNewExtension;
return;
} else {
// This "new" extension is not "newer" than the previous one, let's ignore it
return;
}
}
}
// Finally it's not a duplicate, let's add it to the list
$this->aExtensions[$oNewExtension->sCode.'/'.$oNewExtension->sVersion] = $oNewExtension;
$this->aExtensionsByCode[$oNewExtension->sCode] = $oNewExtension;
}
public function RemoveExtension(string $sCode): void
{
$oExtension = $this->GetFromExtensionCode($sCode);
if (is_null($oExtension)) {
\IssueLog::Error(__METHOD__.": cannot find extension to remove", null, [$sCode]);
return;
}
\IssueLog::Debug(__METHOD__.": remove extension from map", null, [$oExtension->sCode => $oExtension->sSourceDir]);
unset($this->aExtensions[$oExtension->sCode.'/'.$oExtension->sVersion]);
unset($this->aExtensionsByCode[$sCode]);
}
/**
* @since 3.3.0
* @param string $sExtensionCode
*
* @return \iTopExtension|null
*/
public function GetFromExtensionCode(string $sExtensionCode): ?iTopExtension
{
return $this->aExtensionsByCode[$sExtensionCode] ?? null;
}
/**
* @param array<string> $aExtensionCodes
* @return void
*/
public function DeclareExtensionAsRemoved(array $aExtensionCodes): void
{
$aRemovedExtension = [];
foreach ($aExtensionCodes as $sCode) {
/** @var \iTopExtension $oExtension */
$oExtension = $this->GetFromExtensionCode($sCode);
if (!is_null($oExtension)) {
$aRemovedExtension [] = $oExtension;
\IssueLog::Info(__METHOD__.": remove extension locally", null, ['extension_code' => $oExtension->sCode]);
} else {
\IssueLog::Warning(__METHOD__." cannot find extensions", null, ['code' => $sCode]);
}
}
\ModuleDiscovery::DeclareRemovedExtensions($aRemovedExtension);
}
/**
* Read (recursively) a directory to find if it contains extensions (or modules)
*
* @param string $sSearchDir The directory to scan
* @param string $sSource The 'source' value for the extensions found in this directory
* @param string|null $sParentExtensionId Not null if the directory is under a declared extension
*
* @return boolean false if we cannot open dir
*/
protected function ReadDir($sSearchDir, $sSource, $sParentExtensionId = null)
{
if (!is_readable($sSearchDir)) {
return false;
}
$hDir = opendir($sSearchDir);
if ($hDir !== false) {
if ($sParentExtensionId == null) {
// We're not recursing, let's add the directory to the list of scanned dirs
$this->aScannedDirs[] = $sSearchDir;
}
$sExtensionId = null;
$aSubDirectories = [];
// First check if there is an extension.xml file in this directory
if (is_readable($sSearchDir.'/extension.xml')) {
$oXml = new XMLParameters($sSearchDir.'/extension.xml');
$oExtension = new iTopExtension();
$oExtension->sCode = $oXml->Get('extension_code');
$oExtension->sLabel = $oXml->Get('label');
$oExtension->sDescription = $oXml->Get('description');
$oExtension->sVersion = $oXml->Get('version');
$oExtension->bMandatory = ($oXml->Get('mandatory') == 'true');
$oExtension->sMoreInfoUrl = $oXml->Get('more_info_url');
$oExtension->sSource = $sSource;
$oExtension->sSourceDir = $sSearchDir;
$sParentExtensionId = $sExtensionId = $oExtension->sCode.'/'.$oExtension->sVersion;
$this->AddExtension($oExtension);
}
// Then scan the other files and subdirectories
while (($sFile = readdir($hDir)) !== false) {
if (($sFile !== '.') && ($sFile !== '..')) {
$aMatches = [];
if (is_dir($sSearchDir.'/'.$sFile)) {
// Recurse after parsing all the regular files
$aSubDirectories[] = $sSearchDir.'/'.$sFile;
} elseif (preg_match('/^module\.(.*).php$/i', $sFile, $aMatches)) {
// Found a module
try {
$aModuleInfo = ModuleFileReader::GetInstance()->ReadModuleFileInformation($sSearchDir.'/'.$sFile);
} catch (ModuleFileReaderException $e) {
continue;
}
// If we are not already inside a formal extension, then the module itself is considered
// as an extension, otherwise, the module is just added to the list of modules belonging
// to this extension
$sModuleId = $aModuleInfo[ModuleFileReader::MODULE_INFO_ID];
list($sModuleName, $sModuleVersion) = ModuleDiscovery::GetModuleName($sModuleId);
$aModuleInfo[ModuleFileReader::MODULE_INFO_CONFIG]['uninstallable'] ??= 'yes';
$oExtension = null;
if ($sParentExtensionId !== null) {
$oExtension = $this->aExtensions[$sParentExtensionId] ?? null;
}
if (is_null($oExtension)) {
// Not already inside an folder containing an 'extension.xml' file
// Ignore non-visible modules and auto-select ones, since these are never prompted
// as a choice to the end-user
$bVisible = true;
if (!$aModuleInfo[ModuleFileReader::MODULE_INFO_CONFIG]['visible'] || isset($aModuleInfo[ModuleFileReader::MODULE_INFO_CONFIG]['auto_select'])) {
$bVisible = false;
}
// Let's create a "fake" extension from this module (containing just this module) for backwards compatibility
$oExtension = new iTopExtension();
$oExtension->sCode = $sModuleName;
$oExtension->sLabel = $aModuleInfo[ModuleFileReader::MODULE_INFO_CONFIG]['label'];
$oExtension->sDescription = '';
$oExtension->sVersion = $sModuleVersion;
$oExtension->sSource = $sSource;
$oExtension->bMandatory = $aModuleInfo[ModuleFileReader::MODULE_INFO_CONFIG]['mandatory'];
$oExtension->sMoreInfoUrl = $aModuleInfo[ModuleFileReader::MODULE_INFO_CONFIG]['doc.more_information'];
$oExtension->aModules = [$sModuleName];
$oExtension->aModuleVersion[$sModuleName] = $sModuleVersion;
$oExtension->aModuleInfo[$sModuleName] = $aModuleInfo[ModuleFileReader::MODULE_INFO_CONFIG];
$oExtension->sSourceDir = $sSearchDir;
$oExtension->bVisible = $bVisible;
$this->AddExtension($oExtension);
} else {
$oExtension->aModules[] = $sModuleName;
$oExtension->aModuleVersion[$sModuleName] = $sModuleVersion;
$oExtension->aModuleInfo[$sModuleName] = $aModuleInfo[ModuleFileReader::MODULE_INFO_CONFIG];
$this->aExtensions[$sParentExtensionId] = $oExtension;
$this->aExtensionsByCode[$oExtension->sCode] = $oExtension;
}
closedir($hDir);
return true; // we found a module, no more digging necessary !
}
}
}
closedir($hDir);
foreach ($aSubDirectories as $sDir) {
// Recurse inside the subdirectories
$this->ReadDir($sDir, $sSource, $sExtensionId);
}
return true;
}
return false;
}
/**
* Check if some extension contains a module with missing dependencies...
* If so, populate the aMissingDepenencies array
* @return void
*/
protected function CheckDependencies()
{
$aSearchDirs = [];
if (is_dir(APPROOT.'/datamodels/2.x')) {
$aSearchDirs[] = APPROOT.'/datamodels/2.x';
} elseif (is_dir(APPROOT.'/datamodels/1.x')) {
$aSearchDirs[] = APPROOT.'/datamodels/1.x';
}
$aSearchDirs = array_merge($aSearchDirs, $this->aScannedDirs);
try {
ModuleDiscovery::GetAvailableModules($aSearchDirs, true);
} catch (MissingDependencyException $e) {
// Some modules have missing dependencies
// Let's check what is the impact at the "extensions" level
foreach ($this->aExtensions as $sKey => $oExtension) {
foreach ($oExtension->aModules as $sModuleName) {
if (array_key_exists($sModuleName, $oExtension->aModuleVersion)) {
// This information is not available for pseudo modules defined in the installation wizard, but let's ignore them
$sVersion = $oExtension->aModuleVersion[$sModuleName];
$sModuleId = $sModuleName.'/'.$sVersion;
if (array_key_exists($sModuleId, $e->aModulesInfo)) {
// The extension actually contains a module which has unmet dependencies
$aModuleInfo = $e->aModulesInfo[$sModuleId];
$this->aExtensions[$sKey]->aMissingDependencies = array_merge($oExtension->aMissingDependencies, $aModuleInfo['dependencies']);
}
}
}
}
}
}
/**
* Get all available extensions
* @return iTopExtension[]
*/
public function GetAllExtensions()
{
return $this->aExtensions;
}
/**
* @return array All available extensions and extensions currently installed but not available due to files removal
*/
public function GetAllExtensionsWithPreviouslyInstalled(): array
{
//Mind the order, local extensions data must overwrite installed extensions data since installed extensions does not have the associated modules.
return array_merge($this->aInstalledExtensions ?? [], $this->aExtensions);
}
/**
* @param bool $bKeepMissingDependencyExtensions
*
* @return array<\iTopExtension>>
*/
public function GetAllExtensionsToDisplayInSetup(bool $bKeepMissingDependencyExtensions = false): array
{
$aRes = [];
foreach ($this->GetAllExtensionsWithPreviouslyInstalled() as $oExtension) {
\IssueLog::Error($oExtension->sCode.' '.__METHOD__.__LINE__);
/** @var \iTopExtension $oExtension */
if (($oExtension->sSource !== iTopExtension::SOURCE_WIZARD) && ($oExtension->bVisible)) {
if ($bKeepMissingDependencyExtensions || (count($oExtension->aMissingDependencies) == 0)) {
if (!$oExtension->bMandatory) {
$oExtension->bMandatory = ($oExtension->sSource === iTopExtension::SOURCE_REMOTE);
}
$aRes[$oExtension->sCode] = $oExtension;
}
}
}
return $aRes;
}
public function GetAllExtensionsOptionInfo(): array
{
$aRes = [];
foreach ($this->GetAllExtensionsToDisplayInSetup() as $sCode => $oExtension) {
$aRes[] = [
'extension_code' => $oExtension->sCode,
'title' => $oExtension->sLabel,
'description' => $oExtension->sDescription,
'more_info' => $oExtension->sMoreInfoUrl,
'default' => true, // by default offer to install all modules
'modules' => $oExtension->aModules,
'mandatory' => $oExtension->bMandatory,
'source_label' => $this->GetExtensionSourceLabel($oExtension->sSource),
'uninstallable' => $oExtension->CanBeUninstalled(),
'missing' => $oExtension->bRemovedFromDisk,
];
}
return $aRes;
}
protected function GetExtensionSourceLabel($sSource)
{
$sDecorationClass = '';
switch ($sSource) {
case iTopExtension::SOURCE_MANUAL:
$sResult = 'Local extensions folder';
$sDecorationClass = 'fas fa-folder';
break;
case iTopExtension::SOURCE_REMOTE:
$sResult = (ITOP_APPLICATION == 'iTop') ? 'iTop Hub' : 'ITSM Designer';
$sDecorationClass = (ITOP_APPLICATION == 'iTop') ? 'fc fc-chameleon-icon' : 'fa pencil-ruler';
break;
default:
$sResult = '';
}
if ($sResult == '') {
return '';
}
return '<i class="setup-extension--icon '.$sDecorationClass.'" data-tooltip-content="'.$sResult.'"></i>';
}
/**
* Mark the given extension as chosen
* @param string $sExtensionCode The code of the extension (code without version number)
* @param bool $bMark The value to set for the bMarkAsChosen flag
* @return void
*/
public function MarkAsChosen($sExtensionCode, $bMark = true)
{
$oExtension = $this->GetFromExtensionCode($sExtensionCode);
if (!is_null($oExtension)) {
$oExtension->bMarkedAsChosen = $bMark;
}
}
public function MarkAsUninstallable($sExtensionCode, $bMark = true)
{
$oExtension = $this->GetFromExtensionCode($sExtensionCode);
if (!is_null($oExtension)) {
$oExtension->bUninstallable = $bMark;
}
}
/**
* Tells if a given extension(code) is marked as chosen
* @param string $sExtensionCode
* @return boolean
*/
public function IsMarkedAsChosen($sExtensionCode)
{
$oExtension = $this->GetFromExtensionCode($sExtensionCode);
if (!is_null($oExtension)) {
return $oExtension->bMarkedAsChosen;
}
return false;
}
/**
* Set the 'installed_version' of the given extension(code)
* @param string $sExtensionCode
* @param string $sInstalledVersion
* @return void
*/
protected function SetInstalledVersion($sExtensionCode, $sInstalledVersion)
{
$oExtension = $this->GetFromExtensionCode($sExtensionCode);
if (!is_null($oExtension)) {
$oExtension->sInstalledVersion = $sInstalledVersion;
}
}
/**
* Get the list of the "chosen" extensions
* @return iTopExtension[]
*/
public function GetChoices()
{
$aResult = [];
foreach ($this->aExtensions as $oExtension) {
if ($oExtension->bMarkedAsChosen) {
$aResult[] = $oExtension;
}
}
return $aResult;
}
/**
* Load the choices (i.e. MarkedAsChosen) from the database defined in the supplied Config
* @param Config $oConfig
* @return bool
*/
public function LoadChoicesFromDatabase(Config $oConfig)
{
$aLoadInstalledExtensionsFromDatabase = $this->LoadInstalledExtensionsFromDatabase($oConfig);
if (false === $aLoadInstalledExtensionsFromDatabase) {
return false;
}
foreach ($aLoadInstalledExtensionsFromDatabase as $oExtension) {
$this->MarkAsChosen($oExtension->sCode);
$this->SetInstalledVersion($oExtension->sCode, $oExtension->sVersion);
}
return true;
}
public function LoadInstalledExtensionsFromDatabase(Config $oConfig): array|false
{
try {
if (CMDBSource::DBName() === null) {
CMDBSource::InitFromConfig($oConfig);
}
$sLatestInstallationDate = CMDBSource::QueryToScalar("SELECT max(installed) FROM ".$oConfig->Get('db_subname')."priv_extension_install");
$aDBInfo = CMDBSource::QueryToArray("SELECT * FROM ".$oConfig->Get('db_subname')."priv_extension_install WHERE installed = '".$sLatestInstallationDate."'");
$this->aInstalledExtensions = [];
foreach ($aDBInfo as $aExtensionInfo) {
$oExtension = new iTopExtension();
$oExtension->sCode = $aExtensionInfo['code'];
$oExtension->sLabel = $aExtensionInfo['label'];
$oExtension->sDescription = $aExtensionInfo['description'] ?? '';
$oExtension->sVersion = $aExtensionInfo['version'];
$oExtension->sSource = $aExtensionInfo['source'];
$oExtension->bMandatory = false;
$oExtension->sMoreInfoUrl = '';
$oExtension->aModules = [];
$oExtension->aModuleVersion = [];
$oExtension->aModuleInfo = [];
$oExtension->sSourceDir = '';
$oExtension->bVisible = true;
$oExtension->bInstalled = true;
$oExtension->bCanBeUninstalled = !isset($aExtensionInfo['uninstallable']) || $aExtensionInfo['uninstallable'] === 'yes';
$oChoice = $this->GetFromExtensionCode($oExtension->sCode);
if ($oChoice) {
$oChoice->bInstalled = true;
} else {
$oExtension->bRemovedFromDisk = true;
}
$this->aInstalledExtensions[$oExtension->sCode.'/'.$oExtension->sVersion] = $oExtension;
}
return $this->aInstalledExtensions;
} catch (MySQLException $e) {
// No database or erroneous information
return false;
}
}
/**
* Tells if the given module name is "chosen" since it is part of a "chosen" extension (in the specified source dir)
* @param string $sModuleNameToFind
* @param string $sInSourceOnly
* @return boolean
*/
public function ModuleIsChosenAsPartOfAnExtension($sModuleNameToFind, $sInSourceOnly = iTopExtension::SOURCE_REMOTE)
{
foreach ($this->GetAllExtensions() as $oExtension) {
if (($oExtension->sSource == $sInSourceOnly) &&
($oExtension->bMarkedAsChosen == true) &&
(array_key_exists($sModuleNameToFind, $oExtension->aModuleVersion))) {
return true;
}
}
return false;
}
}