3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
13 * The extension manager handles installing, disabling enabling, and
14 * uninstalling extensions.
17 * @copyright CiviCRM LLC https://civicrm.org/licensing
19 class CRM_Extension_Manager
{
21 * The extension is fully installed and enabled.
23 const STATUS_INSTALLED
= 'installed';
26 * The extension config has been applied to database but deactivated.
28 const STATUS_DISABLED
= 'disabled';
31 * The extension code is visible, but nothing has been applied to DB
33 const STATUS_UNINSTALLED
= 'uninstalled';
36 * The extension code is not locally accessible
38 const STATUS_UNKNOWN
= 'unknown';
41 * The extension is installed but the code is not accessible
43 const STATUS_INSTALLED_MISSING
= 'installed-missing';
46 * The extension was installed and is now disabled; the code is not accessible
48 const STATUS_DISABLED_MISSING
= 'disabled-missing';
51 * @var CRM_Extension_Container_Interface
53 * Note: Treat as private. This is only public to facilitate debugging.
55 public $fullContainer;
60 * @var CRM_Extension_Container_Basic|false
62 * Note: Treat as private. This is only public to facilitate debugging.
64 public $defaultContainer;
69 * @var CRM_Extension_Mapper
71 * Note: Treat as private. This is only public to facilitate debugging.
80 * Format is (typeName => CRM_Extension_Manager_Interface)
82 * Note: Treat as private. This is only public to facilitate debugging.
91 * Format is (extensionKey => statusConstant)
93 * Note: Treat as private. This is only public to facilitate debugging.
100 * @param CRM_Extension_Container_Interface $fullContainer
101 * @param CRM_Extension_Container_Basic|FALSE $defaultContainer
102 * @param CRM_Extension_Mapper $mapper
103 * @param array $typeManagers
105 public function __construct(CRM_Extension_Container_Interface
$fullContainer, $defaultContainer, CRM_Extension_Mapper
$mapper, $typeManagers) {
106 $this->fullContainer
= $fullContainer;
107 $this->defaultContainer
= $defaultContainer;
108 $this->mapper
= $mapper;
109 $this->typeManagers
= $typeManagers;
113 * Install or upgrade the code for an extension -- and perform any
114 * necessary database changes (eg replacing extension metadata).
116 * This only works if the extension is stored in the default container.
118 * @param string $tmpCodeDir
119 * Path to a local directory containing a copy of the new (inert) code.
120 * @throws CRM_Extension_Exception
122 public function replace($tmpCodeDir) {
123 if (!$this->defaultContainer
) {
124 throw new CRM_Extension_Exception("Default extension container is not configured");
127 $newInfo = CRM_Extension_Info
::loadFromFile($tmpCodeDir . DIRECTORY_SEPARATOR
. CRM_Extension_Info
::FILENAME
);
128 $oldStatus = $this->getStatus($newInfo->key
);
130 // find $tgtPath, $oldInfo, $typeManager
131 switch ($oldStatus) {
132 case self
::STATUS_UNINSTALLED
:
133 case self
::STATUS_INSTALLED
:
134 case self
::STATUS_DISABLED
:
135 // There is an old copy of the extension. Try to install in the same place -- but it must go somewhere in the default-container
137 list ($oldInfo, $typeManager) = $this->_getInfoTypeHandler($newInfo->key
);
138 $tgtPath = $this->fullContainer
->getPath($newInfo->key
);
139 if (!CRM_Utils_File
::isChildPath($this->defaultContainer
->getBaseDir(), $tgtPath)) {
140 // force installation in the default-container
142 $tgtPath = $this->defaultContainer
->getBaseDir() . DIRECTORY_SEPARATOR
. $newInfo->key
;
143 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.', [
150 case self
::STATUS_INSTALLED_MISSING
:
151 case self
::STATUS_DISABLED_MISSING
:
152 // the extension does not exist in any container; we're free to put it anywhere
153 $tgtPath = $this->defaultContainer
->getBaseDir() . DIRECTORY_SEPARATOR
. $newInfo->key
;
155 list ($oldInfo, $typeManager) = $this->_getMissingInfoTypeHandler($newInfo->key
);
158 case self
::STATUS_UNKNOWN
:
159 // the extension does not exist in any container; we're free to put it anywhere
160 $tgtPath = $this->defaultContainer
->getBaseDir() . DIRECTORY_SEPARATOR
. $newInfo->key
;
161 $oldInfo = $typeManager = NULL;
165 throw new CRM_Extension_Exception("Cannot install or enable extension: {$newInfo->key}");
169 switch ($oldStatus) {
170 case self
::STATUS_UNINSTALLED
:
171 case self
::STATUS_UNKNOWN
:
172 // There are no DB records to worry about, so we'll just put the files in place
173 if (!CRM_Utils_File
::replaceDir($tmpCodeDir, $tgtPath)) {
174 throw new CRM_Extension_Exception("Failed to move $tmpCodeDir to $tgtPath");
178 case self
::STATUS_INSTALLED
:
179 case self
::STATUS_INSTALLED_MISSING
:
180 case self
::STATUS_DISABLED
:
181 case self
::STATUS_DISABLED_MISSING
:
182 // There are DB records; coordinate the file placement with the DB updates
183 $typeManager->onPreReplace($oldInfo, $newInfo);
184 if (!CRM_Utils_File
::replaceDir($tmpCodeDir, $tgtPath)) {
185 throw new CRM_Extension_Exception("Failed to move $tmpCodeDir to $tgtPath");
187 $this->_updateExtensionEntry($newInfo);
188 $typeManager->onPostReplace($oldInfo, $newInfo);
192 throw new CRM_Extension_Exception("Cannot install or enable extension: {$newInfo->key}");
196 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
200 * Add records of the extension to the database -- and enable it
202 * @param string|array $keys
203 * One or more extension keys.
204 * @throws CRM_Extension_Exception
206 public function install($keys) {
207 $keys = (array) $keys;
208 $origStatuses = $this->getStatuses();
210 // TODO: to mitigate the risk of crashing during installation, scan
211 // keys/statuses/types before doing anything
213 // Check compatibility
215 foreach ($keys as $key) {
216 if ($this->isIncompatible($key)) {
217 $incompatible[] = $key;
221 throw new CRM_Extension_Exception('Cannot install incompatible extension: ' . implode(', ', $incompatible));
224 foreach ($keys as $key) {
225 /** @var CRM_Extension_Info $info */
226 /** @var CRM_Extension_Manager_Base $typeManager */
227 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
229 switch ($origStatuses[$key]) {
230 case self
::STATUS_INSTALLED
:
234 case self
::STATUS_DISABLED
:
236 $typeManager->onPreEnable($info);
237 $this->_setExtensionActive($info, 1);
238 $typeManager->onPostEnable($info);
240 // A full refresh would be preferrable but very slow. This at least allows
241 // later extensions to access classes from earlier extensions.
242 $this->statuses
= NULL;
243 $this->mapper
->refresh();
246 case self
::STATUS_UNINSTALLED
:
248 $typeManager->onPreInstall($info);
249 $this->_createExtensionEntry($info);
250 $typeManager->onPostInstall($info);
252 // A full refresh would be preferrable but very slow. This at least allows
253 // later extensions to access classes from earlier extensions.
254 $this->statuses
= NULL;
255 $this->mapper
->refresh();
258 case self
::STATUS_UNKNOWN
:
260 throw new CRM_Extension_Exception("Cannot install or enable extension: $key");
264 $this->statuses
= NULL;
265 $this->mapper
->refresh();
266 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
267 $schema = new CRM_Logging_Schema();
268 $schema->fixSchemaDifferences();
270 foreach ($keys as $key) {
272 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
274 switch ($origStatuses[$key]) {
275 case self
::STATUS_INSTALLED
:
279 case self
::STATUS_DISABLED
:
283 case self
::STATUS_UNINSTALLED
:
285 $typeManager->onPostPostInstall($info);
288 case self
::STATUS_UNKNOWN
:
290 throw new CRM_Extension_Exception("Cannot install or enable extension: $key");
297 * Add records of the extension to the database -- and enable it
300 * List of extension keys.
301 * @throws CRM_Extension_Exception
303 public function enable($keys) {
304 $this->install($keys);
308 * Disable extension without removing record from db.
310 * @param string|array $keys
311 * One or more extension keys.
312 * @throws CRM_Extension_Exception
314 public function disable($keys) {
315 $keys = (array) $keys;
316 $origStatuses = $this->getStatuses();
318 // TODO: to mitigate the risk of crashing during installation, scan
319 // keys/statuses/types before doing anything
322 $disableRequirements = $this->findDisableRequirements($keys);
323 // This munges order, but makes it comparable.
324 sort($disableRequirements);
325 if ($keys !== $disableRequirements) {
326 throw new CRM_Extension_Exception_DependencyException("Cannot disable extension due to dependencies. Consider disabling all these: " . implode(',', $disableRequirements));
329 foreach ($keys as $key) {
330 switch ($origStatuses[$key]) {
331 case self
::STATUS_INSTALLED
:
333 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
334 $typeManager->onPreDisable($info);
335 $this->_setExtensionActive($info, 0);
336 $typeManager->onPostDisable($info);
339 case self
::STATUS_INSTALLED_MISSING
:
341 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key);
342 $typeManager->onPreDisable($info);
343 $this->_setExtensionActive($info, 0);
344 $typeManager->onPostDisable($info);
347 case self
::STATUS_DISABLED
:
348 case self
::STATUS_DISABLED_MISSING
:
349 case self
::STATUS_UNINSTALLED
:
353 case self
::STATUS_UNKNOWN
:
355 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
359 $this->statuses
= NULL;
360 $this->mapper
->refresh();
361 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
365 * Remove all database references to an extension.
367 * @param string|array $keys
368 * One or more extension keys.
369 * @throws CRM_Extension_Exception
371 public function uninstall($keys) {
372 $keys = (array) $keys;
373 $origStatuses = $this->getStatuses();
375 // TODO: to mitigate the risk of crashing during installation, scan
376 // keys/statuses/types before doing anything
378 foreach ($keys as $key) {
379 switch ($origStatuses[$key]) {
380 case self
::STATUS_INSTALLED
:
381 case self
::STATUS_INSTALLED_MISSING
:
382 throw new CRM_Extension_Exception("Cannot uninstall extension; disable it first: $key");
384 case self
::STATUS_DISABLED
:
386 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
387 $typeManager->onPreUninstall($info);
388 $this->_removeExtensionEntry($info);
389 $typeManager->onPostUninstall($info);
392 case self
::STATUS_DISABLED_MISSING
:
394 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key);
395 $typeManager->onPreUninstall($info);
396 $this->_removeExtensionEntry($info);
397 $typeManager->onPostUninstall($info);
400 case self
::STATUS_UNINSTALLED
:
404 case self
::STATUS_UNKNOWN
:
406 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
410 $this->statuses
= NULL;
411 $this->mapper
->refresh();
412 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
416 * Determine the status of an extension.
421 * constant self::STATUS_*
423 public function getStatus($key) {
424 $statuses = $this->getStatuses();
425 if (array_key_exists($key, $statuses)) {
426 return $statuses[$key];
429 return self
::STATUS_UNKNOWN
;
434 * Check if a given extension is incompatible with this version of CiviCRM
439 public function isIncompatible($key) {
440 $info = CRM_Extension_System
::getCompatibilityInfo();
441 return $info[$key] ??
FALSE;
445 * Determine the status of all extensions.
448 * ($key => status_constant)
450 public function getStatuses() {
451 if (!is_array($this->statuses
)) {
452 $compat = CRM_Extension_System
::getCompatibilityInfo();
454 $this->statuses
= [];
456 foreach ($this->fullContainer
->getKeys() as $key) {
457 $this->statuses
[$key] = self
::STATUS_UNINSTALLED
;
461 SELECT full_name, is_active
462 FROM civicrm_extension
464 $dao = CRM_Core_DAO
::executeQuery($sql);
465 while ($dao->fetch()) {
467 $path = $this->fullContainer
->getPath($dao->full_name
);
468 $codeExists = !empty($path) && is_dir($path);
470 catch (CRM_Extension_Exception
$e) {
473 if (!empty($compat[$dao->full_name
]['force-uninstall'])) {
474 $this->statuses
[$dao->full_name
] = self
::STATUS_UNINSTALLED
;
476 elseif ($dao->is_active
) {
477 $this->statuses
[$dao->full_name
] = $codeExists ? self
::STATUS_INSTALLED
: self
::STATUS_INSTALLED_MISSING
;
480 $this->statuses
[$dao->full_name
] = $codeExists ? self
::STATUS_DISABLED
: self
::STATUS_DISABLED_MISSING
;
484 return $this->statuses
;
487 public function refresh() {
488 $this->statuses
= NULL;
489 // and, indirectly, defaultContainer
490 $this->fullContainer
->refresh();
491 $this->mapper
->refresh();
494 // ----------------------
497 * Find the $info and $typeManager for a $key
501 * @throws CRM_Extension_Exception
503 * [CRM_Extension_Info, CRM_Extension_Manager_Interface]
505 private function _getInfoTypeHandler($key) {
507 $info = $this->mapper
->keyToInfo($key);
508 if (array_key_exists($info->type
, $this->typeManagers
)) {
509 return [$info, $this->typeManagers
[$info->type
]];
512 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type
);
517 * Find the $info and $typeManager for a $key
521 * @throws CRM_Extension_Exception
523 * [CRM_Extension_Info, CRM_Extension_Manager_Interface]
525 private function _getMissingInfoTypeHandler($key) {
526 $info = $this->createInfoFromDB($key);
528 if (array_key_exists($info->type
, $this->typeManagers
)) {
529 return [$info, $this->typeManagers
[$info->type
]];
532 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type
);
536 throw new CRM_Extension_Exception("Failed to reconstruct missing extension: " . $key);
541 * @param CRM_Extension_Info $info
545 private function _createExtensionEntry(CRM_Extension_Info
$info) {
546 $dao = new CRM_Core_DAO_Extension();
547 $dao->label
= $info->label
;
548 $dao->name
= $info->name
;
549 $dao->full_name
= $info->key
;
550 $dao->type
= $info->type
;
551 $dao->file
= $info->file
;
553 return (bool) ($dao->insert());
557 * @param CRM_Extension_Info $info
561 private function _updateExtensionEntry(CRM_Extension_Info
$info) {
562 $dao = new CRM_Core_DAO_Extension();
563 $dao->full_name
= $info->key
;
564 if ($dao->find(TRUE)) {
565 $dao->label
= $info->label
;
566 $dao->name
= $info->name
;
567 $dao->full_name
= $info->key
;
568 $dao->type
= $info->type
;
569 $dao->file
= $info->file
;
571 return (bool) ($dao->update());
574 return $this->_createExtensionEntry($info);
579 * @param CRM_Extension_Info $info
581 * @throws CRM_Extension_Exception
583 private function _removeExtensionEntry(CRM_Extension_Info
$info) {
584 $dao = new CRM_Core_DAO_Extension();
585 $dao->full_name
= $info->key
;
586 if ($dao->find(TRUE)) {
587 if (CRM_Core_BAO_Extension
::del($dao->id
)) {
588 CRM_Core_Session
::setStatus(ts('Selected option value has been deleted.'), ts('Deleted'), 'success');
591 throw new CRM_Extension_Exception("Failed to remove extension entry");
593 } // else: post-condition already satisified
597 * @param CRM_Extension_Info $info
600 private function _setExtensionActive(CRM_Extension_Info
$info, $isActive) {
601 CRM_Core_DAO
::executeQuery('UPDATE civicrm_extension SET is_active = %1 where full_name = %2', [
602 1 => [$isActive, 'Integer'],
603 2 => [$info->key
, 'String'],
608 * Auto-generate a place-holder for a missing extension using info from
612 * @return CRM_Extension_Info|NULL
614 public function createInfoFromDB($key) {
615 $dao = new CRM_Core_DAO_Extension();
616 $dao->full_name
= $key;
617 if ($dao->find(TRUE)) {
618 $info = new CRM_Extension_Info($dao->full_name
, $dao->type
, $dao->name
, $dao->label
, $dao->file
);
627 * Build a list of extensions to install, in an order that will satisfy dependencies.
630 * List of extensions to install.
631 * @param \CRM_Extension_Info $info
632 * An extension info object that we should use instead of our local versions (eg. when checking for upgradeability).
635 * List of extension keys, including dependencies, in order of installation.
636 * @throws \CRM_Extension_Exception
637 * @throws \MJS\TopSort\CircularDependencyException
638 * @throws \MJS\TopSort\ElementNotFoundException
640 public function findInstallRequirements($keys, $info = NULL) {
641 // Use our passed in info, or get the local versions
643 $infos[$info->key
] = $info;
646 $infos = $this->mapper
->getAllInfos();
648 // array(string $key).
649 $todoKeys = array_unique($keys);
650 // array(string $key => 1);
652 $sorter = new \MJS\TopSort\Implementations\
FixedArraySort();
654 while (!empty($todoKeys)) {
655 $key = array_shift($todoKeys);
656 if (isset($doneKeys[$key])) {
661 /** @var CRM_Extension_Info $info */
662 $info = @$infos[$key];
664 if ($info && $info->requires
) {
665 $sorter->add($key, $info->requires
);
666 $todoKeys = array_merge($todoKeys, $info->requires
);
669 $sorter->add($key, []);
672 return $sorter->sort();
676 * Build a list of extensions to remove, in an order that will satisfy dependencies.
679 * List of extensions to install.
681 * List of extension keys, including dependencies, in order of removal.
683 public function findDisableRequirements($keys) {
685 self
::STATUS_INSTALLED
,
686 self
::STATUS_INSTALLED_MISSING
,
688 $installedInfos = $this->filterInfosByStatus($this->mapper
->getAllInfos(), $INSTALLED);
689 $revMap = CRM_Extension_Info
::buildReverseMap($installedInfos);
690 $todoKeys = array_unique($keys);
692 $sorter = new \MJS\TopSort\Implementations\
FixedArraySort();
694 while (!empty($todoKeys)) {
695 $key = array_shift($todoKeys);
696 if (isset($doneKeys[$key])) {
701 if (isset($revMap[$key])) {
702 $requiredBys = CRM_Utils_Array
::collect('key',
703 $this->filterInfosByStatus($revMap[$key], $INSTALLED));
704 $sorter->add($key, $requiredBys);
705 $todoKeys = array_merge($todoKeys, $requiredBys);
708 $sorter->add($key, []);
711 return $sorter->sort();
716 * @param $filterStatuses
719 protected function filterInfosByStatus($infos, $filterStatuses) {
721 foreach ($infos as $k => $v) {
722 if (in_array($this->getStatus($v->key
), $filterStatuses)) {