getManager(); * * @package CRM * @copyright CiviCRM LLC https://civicrm.org/licensing */ class CRM_Extension_Manager { /** * The extension is fully installed and enabled. */ const STATUS_INSTALLED = 'installed'; /** * The extension config has been applied to database but deactivated. */ const STATUS_DISABLED = 'disabled'; /** * The extension code is visible, but nothing has been applied to DB */ const STATUS_UNINSTALLED = 'uninstalled'; /** * The extension code is not locally accessible */ const STATUS_UNKNOWN = 'unknown'; /** * The extension is installed but the code is not accessible */ const STATUS_INSTALLED_MISSING = 'installed-missing'; /** * The extension was installed and is now disabled; the code is not accessible */ const STATUS_DISABLED_MISSING = 'disabled-missing'; /** * @var CRM_Extension_Container_Interface * * Note: Treat as private. This is only public to facilitate debugging. */ public $fullContainer; /** * Default container. * * @var CRM_Extension_Container_Basic|false * * Note: Treat as private. This is only public to facilitate debugging. */ public $defaultContainer; /** * Mapper. * * @var CRM_Extension_Mapper * * Note: Treat as private. This is only public to facilitate debugging. */ public $mapper; /** * Type managers. * * @var array * * Format is (typeName => CRM_Extension_Manager_Interface) * * Note: Treat as private. This is only public to facilitate debugging. */ public $typeManagers; /** * Statuses. * * @var array * * Format is (extensionKey => statusConstant) * * Note: Treat as private. This is only public to facilitate debugging. */ public $statuses; /** * Live process(es) per extension. * * @var array * * Format is: { * extensionKey => [ * ['operation' => 'install|enable|uninstall|disable', 'phase' => 'queued|live|completed' * ... * ], * ... * } * * The inner array is a stack, so the most recent current operation is the * last entry. As this manager handles multiple extensions at once, here's * the flow for an install operation. * * $manager->install(['ext1', 'ext2']); * * 0. {} * 1. { ext1: ['install'], ext2: ['install'] } * 2. { ext1: ['install', 'installing'], ext2: ['install'] } * 3. { ext1: ['install'], ext2: ['install', 'installing'] } * 4. { ext1: ['install'], ext2: ['install'] } * 5. {} */ protected $processes = []; /** * Class constructor. * * @param CRM_Extension_Container_Interface $fullContainer * @param CRM_Extension_Container_Basic|FALSE $defaultContainer * @param CRM_Extension_Mapper $mapper * @param array $typeManagers */ public function __construct(CRM_Extension_Container_Interface $fullContainer, $defaultContainer, CRM_Extension_Mapper $mapper, $typeManagers) { $this->fullContainer = $fullContainer; $this->defaultContainer = $defaultContainer; $this->mapper = $mapper; $this->typeManagers = $typeManagers; } /** * Install or upgrade the code for an extension -- and perform any * necessary database changes (eg replacing extension metadata). * * This only works if the extension is stored in the default container. * * @param string $tmpCodeDir * Path to a local directory containing a copy of the new (inert) code. * @throws CRM_Extension_Exception */ public function replace($tmpCodeDir) { if (!$this->defaultContainer) { throw new CRM_Extension_Exception("Default extension container is not configured"); } $newInfo = CRM_Extension_Info::loadFromFile($tmpCodeDir . DIRECTORY_SEPARATOR . CRM_Extension_Info::FILENAME); $oldStatus = $this->getStatus($newInfo->key); // find $tgtPath, $oldInfo, $typeManager switch ($oldStatus) { case self::STATUS_UNINSTALLED: case self::STATUS_INSTALLED: case self::STATUS_DISABLED: // There is an old copy of the extension. Try to install in the same place -- but it must go somewhere in the default-container // throws Exception list ($oldInfo, $typeManager) = $this->_getInfoTypeHandler($newInfo->key); $tgtPath = $this->fullContainer->getPath($newInfo->key); if (!CRM_Utils_File::isChildPath($this->defaultContainer->getBaseDir(), $tgtPath)) { // force installation in the default-container $oldPath = $tgtPath; $tgtPath = $this->defaultContainer->getBaseDir() . DIRECTORY_SEPARATOR . $newInfo->key; CRM_Core_Session::setStatus(ts('A copy of the extension (%1) is in a system folder (%2). The system copy will be preserved, but the new copy will be used.', [ 1 => $newInfo->key, 2 => $oldPath, ])); } break; case self::STATUS_INSTALLED_MISSING: case self::STATUS_DISABLED_MISSING: // the extension does not exist in any container; we're free to put it anywhere $tgtPath = $this->defaultContainer->getBaseDir() . DIRECTORY_SEPARATOR . $newInfo->key; // throws Exception list ($oldInfo, $typeManager) = $this->_getMissingInfoTypeHandler($newInfo->key); break; case self::STATUS_UNKNOWN: // the extension does not exist in any container; we're free to put it anywhere $tgtPath = $this->defaultContainer->getBaseDir() . DIRECTORY_SEPARATOR . $newInfo->key; $oldInfo = $typeManager = NULL; break; default: throw new CRM_Extension_Exception("Cannot install or enable extension: {$newInfo->key}"); } // move the code! switch ($oldStatus) { case self::STATUS_UNINSTALLED: case self::STATUS_UNKNOWN: // There are no DB records to worry about, so we'll just put the files in place if (!CRM_Utils_File::replaceDir($tmpCodeDir, $tgtPath)) { throw new CRM_Extension_Exception("Failed to move $tmpCodeDir to $tgtPath"); } break; case self::STATUS_INSTALLED: case self::STATUS_INSTALLED_MISSING: case self::STATUS_DISABLED: case self::STATUS_DISABLED_MISSING: // There are DB records; coordinate the file placement with the DB updates $typeManager->onPreReplace($oldInfo, $newInfo); if (!CRM_Utils_File::replaceDir($tmpCodeDir, $tgtPath)) { throw new CRM_Extension_Exception("Failed to move $tmpCodeDir to $tgtPath"); } $this->_updateExtensionEntry($newInfo); $typeManager->onPostReplace($oldInfo, $newInfo); break; default: throw new CRM_Extension_Exception("Cannot install or enable extension: {$newInfo->key}"); } $this->refresh(); CRM_Core_Invoke::rebuildMenuAndCaches(TRUE); } /** * Add records of the extension to the database -- and enable it * * @param string|array $keys * One or more extension keys. * @param string $mode install|enable * @throws CRM_Extension_Exception */ public function install($keys, $mode = 'install') { $keys = (array) $keys; $origStatuses = $this->getStatuses(); // TODO: to mitigate the risk of crashing during installation, scan // keys/statuses/types before doing anything // Check compatibility $incompatible = []; foreach ($keys as $key) { if ($this->isIncompatible($key)) { $incompatible[] = $key; } } if ($incompatible) { throw new CRM_Extension_Exception('Cannot install incompatible extension: ' . implode(', ', $incompatible)); } // Keep state for these operations. $this->addProcess($keys, $mode); foreach ($keys as $key) { /** @var CRM_Extension_Info $info */ /** @var CRM_Extension_Manager_Base $typeManager */ list ($info, $typeManager) = $this->_getInfoTypeHandler($key); switch ($origStatuses[$key]) { case self::STATUS_INSTALLED: // ok, nothing to do. As such the status of this process is no longer // 'install' install was the intent, which might have resulted in // changes but these changes will not be happening, so processes that // are sensitive to installs (like the managed entities reconcile // operation) should not assume that these changes have happened. $this->popProcess([$key]); break; case self::STATUS_DISABLED: // re-enable it $this->addProcess([$key], 'enabling'); $typeManager->onPreEnable($info); $this->_setExtensionActive($info, 1); $typeManager->onPostEnable($info); // A full refresh would be preferrable but very slow. This at least allows // later extensions to access classes from earlier extensions. $this->statuses = NULL; $this->mapper->refresh(); $this->popProcess([$key]); break; case self::STATUS_UNINSTALLED: // install anew $this->addProcess([$key], 'installing'); $typeManager->onPreInstall($info); $this->_createExtensionEntry($info); $typeManager->onPostInstall($info); // A full refresh would be preferrable but very slow. This at least allows // later extensions to access classes from earlier extensions. $this->statuses = NULL; $this->mapper->refresh(); $this->popProcess([$key]); break; case self::STATUS_UNKNOWN: default: throw new CRM_Extension_Exception("Cannot install or enable extension: $key"); } } $this->statuses = NULL; $this->mapper->refresh(); if (!CRM_Core_Config::isUpgradeMode()) { CRM_Core_Invoke::rebuildMenuAndCaches(TRUE); $schema = new CRM_Logging_Schema(); $schema->fixSchemaDifferences(); } foreach ($keys as $key) { // throws Exception list ($info, $typeManager) = $this->_getInfoTypeHandler($key); switch ($origStatuses[$key]) { case self::STATUS_INSTALLED: // ok, nothing to do break; case self::STATUS_DISABLED: // re-enable it break; case self::STATUS_UNINSTALLED: // install anew $this->addProcess([$key], 'installing'); $typeManager->onPostPostInstall($info); $this->popProcess([$key]); break; case self::STATUS_UNKNOWN: default: throw new CRM_Extension_Exception("Cannot install or enable extension: $key"); } } // All processes for these keys $this->popProcess($keys); } /** * Add records of the extension to the database -- and enable it * * @param array $keys * List of extension keys. * @throws CRM_Extension_Exception */ public function enable($keys) { $this->install($keys, 'enable'); } /** * Disable extension without removing record from db. * * @param string|array $keys * One or more extension keys. * @throws CRM_Extension_Exception */ public function disable($keys) { $keys = (array) $keys; $origStatuses = $this->getStatuses(); // TODO: to mitigate the risk of crashing during installation, scan // keys/statuses/types before doing anything sort($keys); $disableRequirements = $this->findDisableRequirements($keys); // This munges order, but makes it comparable. sort($disableRequirements); if ($keys !== $disableRequirements) { throw new CRM_Extension_Exception_DependencyException("Cannot disable extension due to dependencies. Consider disabling all these: " . implode(',', $disableRequirements)); } $this->addProcess($keys, 'disable'); foreach ($keys as $key) { if (isset($origStatuses[$key])) { switch ($origStatuses[$key]) { case self::STATUS_INSTALLED: $this->addProcess([$key], 'disabling'); // throws Exception list ($info, $typeManager) = $this->_getInfoTypeHandler($key); $typeManager->onPreDisable($info); $this->_setExtensionActive($info, 0); $typeManager->onPostDisable($info); $this->popProcess([$key]); break; case self::STATUS_INSTALLED_MISSING: // throws Exception list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key); $typeManager->onPreDisable($info); $this->_setExtensionActive($info, 0); $typeManager->onPostDisable($info); break; case self::STATUS_DISABLED: case self::STATUS_DISABLED_MISSING: case self::STATUS_UNINSTALLED: // ok, nothing to do // Remove the 'disable' process as we're not doing that. $this->popProcess([$key]); break; case self::STATUS_UNKNOWN: default: throw new CRM_Extension_Exception("Cannot disable unknown extension: $key"); } } else { throw new CRM_Extension_Exception("Cannot disable unknown extension: $key"); } } $this->statuses = NULL; $this->mapper->refresh(); CRM_Core_Invoke::rebuildMenuAndCaches(TRUE); $this->popProcess($keys); } /** * Remove all database references to an extension. * * @param string|array $keys * One or more extension keys. * @throws CRM_Extension_Exception */ public function uninstall($keys) { $keys = (array) $keys; $origStatuses = $this->getStatuses(); // TODO: to mitigate the risk of crashing during installation, scan // keys/statuses/types before doing anything $this->addProcess($keys, 'uninstall'); foreach ($keys as $key) { switch ($origStatuses[$key]) { case self::STATUS_INSTALLED: case self::STATUS_INSTALLED_MISSING: throw new CRM_Extension_Exception("Cannot uninstall extension; disable it first: $key"); case self::STATUS_DISABLED: $this->addProcess([$key], 'uninstalling'); // throws Exception list ($info, $typeManager) = $this->_getInfoTypeHandler($key); $typeManager->onPreUninstall($info); $this->_removeExtensionEntry($info); $typeManager->onPostUninstall($info); break; case self::STATUS_DISABLED_MISSING: // throws Exception list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key); $typeManager->onPreUninstall($info); $this->_removeExtensionEntry($info); $typeManager->onPostUninstall($info); break; case self::STATUS_UNINSTALLED: // ok, nothing to do // remove the 'uninstall' process since we're not doing that. $this->popProcess([$key]); break; case self::STATUS_UNKNOWN: default: throw new CRM_Extension_Exception("Cannot disable unknown extension: $key"); } } $this->statuses = NULL; $this->mapper->refresh(); CRM_Core_Invoke::rebuildMenuAndCaches(TRUE); $this->popProcess($keys); } /** * Determine the status of an extension. * * @param $key * * @return string * constant self::STATUS_* */ public function getStatus($key) { $statuses = $this->getStatuses(); if (array_key_exists($key, $statuses)) { return $statuses[$key]; } else { return self::STATUS_UNKNOWN; } } /** * Check if a given extension is incompatible with this version of CiviCRM * * @param $key * @return bool|array */ public function isIncompatible($key) { $info = CRM_Extension_System::getCompatibilityInfo(); return $info[$key] ?? FALSE; } /** * Determine the status of all extensions. * * @return array * ($key => status_constant) */ public function getStatuses() { if (!is_array($this->statuses)) { $compat = CRM_Extension_System::getCompatibilityInfo(); $this->statuses = []; foreach ($this->fullContainer->getKeys() as $key) { $this->statuses[$key] = self::STATUS_UNINSTALLED; } $sql = ' SELECT full_name, is_active FROM civicrm_extension '; $dao = CRM_Core_DAO::executeQuery($sql); while ($dao->fetch()) { try { $path = $this->fullContainer->getPath($dao->full_name); $codeExists = !empty($path) && is_dir($path); } catch (CRM_Extension_Exception $e) { $codeExists = FALSE; } if (!empty($compat[$dao->full_name]['force-uninstall'])) { $this->statuses[$dao->full_name] = self::STATUS_UNINSTALLED; } elseif ($dao->is_active) { $this->statuses[$dao->full_name] = $codeExists ? self::STATUS_INSTALLED : self::STATUS_INSTALLED_MISSING; } else { $this->statuses[$dao->full_name] = $codeExists ? self::STATUS_DISABLED : self::STATUS_DISABLED_MISSING; } } } return $this->statuses; } public function refresh() { $this->statuses = NULL; // and, indirectly, defaultContainer $this->fullContainer->refresh(); $this->mapper->refresh(); } /** * Return current processes for given extension. * * @param String $key extension key * * @return array */ public function getActiveProcesses(string $key) :Array { return $this->processes[$key] ?? []; } /** * Determine if the extension specified is currently involved in an install * or enable process. Just sugar code to make things more readable. * * @param String $key extension key * * @return bool */ public function extensionIsBeingInstalledOrEnabled($key) :bool { foreach ($this->getActiveProcesses($key) as $process) { if (in_array($process, ['install', 'installing', 'enable', 'enabling'])) { return TRUE; } } return FALSE; } // ---------------------- /** * Find the $info and $typeManager for a $key * * @param $key * * @throws CRM_Extension_Exception * @return array * [CRM_Extension_Info, CRM_Extension_Manager_Interface] */ private function _getInfoTypeHandler($key) { // throws Exception $info = $this->mapper->keyToInfo($key); if (array_key_exists($info->type, $this->typeManagers)) { return [$info, $this->typeManagers[$info->type]]; } else { throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type); } } /** * Find the $info and $typeManager for a $key * * @param $key * * @throws CRM_Extension_Exception * @return array * [CRM_Extension_Info, CRM_Extension_Manager_Interface] */ private function _getMissingInfoTypeHandler($key) { $info = $this->createInfoFromDB($key); if ($info) { if (array_key_exists($info->type, $this->typeManagers)) { return [$info, $this->typeManagers[$info->type]]; } else { throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type); } } else { throw new CRM_Extension_Exception("Failed to reconstruct missing extension: " . $key); } } /** * @param CRM_Extension_Info $info * * @return bool */ private function _createExtensionEntry(CRM_Extension_Info $info) { $dao = new CRM_Core_DAO_Extension(); $dao->label = $info->label; $dao->name = $info->name; $dao->full_name = $info->key; $dao->type = $info->type; $dao->file = $info->file; $dao->is_active = 1; return (bool) ($dao->insert()); } /** * @param CRM_Extension_Info $info * * @return bool */ private function _updateExtensionEntry(CRM_Extension_Info $info) { $dao = new CRM_Core_DAO_Extension(); $dao->full_name = $info->key; if ($dao->find(TRUE)) { $dao->label = $info->label; $dao->name = $info->name; $dao->full_name = $info->key; $dao->type = $info->type; $dao->file = $info->file; $dao->is_active = 1; return (bool) ($dao->update()); } else { return $this->_createExtensionEntry($info); } } /** * @param CRM_Extension_Info $info * * @throws CRM_Extension_Exception */ private function _removeExtensionEntry(CRM_Extension_Info $info) { $dao = new CRM_Core_DAO_Extension(); $dao->full_name = $info->key; if ($dao->find(TRUE)) { if (CRM_Core_BAO_Extension::del($dao->id)) { CRM_Core_Session::setStatus(ts('Selected option value has been deleted.'), ts('Deleted'), 'success'); } else { throw new CRM_Extension_Exception("Failed to remove extension entry"); } } // else: post-condition already satisified } /** * @param CRM_Extension_Info $info * @param $isActive */ private function _setExtensionActive(CRM_Extension_Info $info, $isActive) { CRM_Core_DAO::executeQuery('UPDATE civicrm_extension SET is_active = %1 where full_name = %2', [ 1 => [$isActive, 'Integer'], 2 => [$info->key, 'String'], ]); } /** * Auto-generate a place-holder for a missing extension using info from * database. * * @param $key * @return CRM_Extension_Info|NULL */ public function createInfoFromDB($key) { $dao = new CRM_Core_DAO_Extension(); $dao->full_name = $key; if ($dao->find(TRUE)) { $info = new CRM_Extension_Info($dao->full_name, $dao->type, $dao->name, $dao->label, $dao->file); return $info; } else { return NULL; } } /** * Build a list of extensions to install, in an order that will satisfy dependencies. * * @param array $keys * List of extensions to install. * @param \CRM_Extension_Info $info * An extension info object that we should use instead of our local versions (eg. when checking for upgradeability). * * @return array * List of extension keys, including dependencies, in order of installation. * @throws \CRM_Extension_Exception * @throws \MJS\TopSort\CircularDependencyException * @throws \MJS\TopSort\ElementNotFoundException */ public function findInstallRequirements($keys, $info = NULL) { // Use our passed in info, or get the local versions if ($info) { $infos[$info->key] = $info; } else { $infos = $this->mapper->getAllInfos(); } // array(string $key). $todoKeys = array_unique($keys); // array(string $key => 1); $doneKeys = []; $sorter = new \MJS\TopSort\Implementations\FixedArraySort(); while (!empty($todoKeys)) { $key = array_shift($todoKeys); if (isset($doneKeys[$key])) { continue; } $doneKeys[$key] = 1; /** @var CRM_Extension_Info $info */ $info = @$infos[$key]; if ($info && $info->requires) { $sorter->add($key, $info->requires); $todoKeys = array_merge($todoKeys, $info->requires); } else { $sorter->add($key, []); } } return $sorter->sort(); } /** * Build a list of extensions to remove, in an order that will satisfy dependencies. * * @param array $keys * List of extensions to install. * @return array * List of extension keys, including dependencies, in order of removal. */ public function findDisableRequirements($keys) { $INSTALLED = [ self::STATUS_INSTALLED, self::STATUS_INSTALLED_MISSING, ]; $installedInfos = $this->filterInfosByStatus($this->mapper->getAllInfos(), $INSTALLED); $revMap = CRM_Extension_Info::buildReverseMap($installedInfos); $todoKeys = array_unique($keys); $doneKeys = []; $sorter = new \MJS\TopSort\Implementations\FixedArraySort(); while (!empty($todoKeys)) { $key = array_shift($todoKeys); if (isset($doneKeys[$key])) { continue; } $doneKeys[$key] = 1; if (isset($revMap[$key])) { $requiredBys = CRM_Utils_Array::collect('key', $this->filterInfosByStatus($revMap[$key], $INSTALLED)); $sorter->add($key, $requiredBys); $todoKeys = array_merge($todoKeys, $requiredBys); } else { $sorter->add($key, []); } } return $sorter->sort(); } /** * Provides way to set processes property for phpunit tests - not for general use. * * @param $processes */ public function setProcessesForTesting(array $processes) { $this->processes = $processes; } /** * @param $infos * @param $filterStatuses * @return array */ protected function filterInfosByStatus($infos, $filterStatuses) { $matches = []; foreach ($infos as $k => $v) { if (in_array($this->getStatus($v->key), $filterStatuses)) { $matches[$k] = $v; } } return $matches; } /** * Add a process to the stacks for the extensions. * * @param array $keys extensionKey * @param string $process one of: install|uninstall|enable|disable|installing|uninstalling|enabling|disabling */ protected function addProcess(array $keys, string $process) { foreach ($keys as $key) { $this->processes[$key][] = $process; } } /** * Pop the top op from the stacks for the extensions. * * @param array $keys extensionKey */ protected function popProcess(array $keys) { foreach ($keys as $key) { if (!empty($this->process[$key])) { array_pop($this->process[$key]); } } } }